Compare commits

..

2 Commits

Author SHA1 Message Date
98ae12d10d Stash 2018-02-08 12:24:32 +01:00
ba40f71aed Define new db tables to host constraints
Also make use of advanced types in SQLAlchemy to clean the db scheme.
2018-02-08 12:24:32 +01:00
121 changed files with 2147 additions and 894917 deletions

View File

@ -1 +0,0 @@
data

View File

@ -1,10 +0,0 @@
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
insert_final_newline = true
[*.py]
max_line_length=120

View File

@ -4,9 +4,6 @@
"env": {
"browser": true
},
"parserOptions": {
"ecmaVersion": 8
},
rules: {
'indent': ["error", 4, { 'SwitchCase': 1 }],
}

4
.gitignore vendored
View File

@ -7,7 +7,5 @@ config/
node_modules
flatisfy/web/static/assets
data/
package-lock.json
doc/_build
data_rework/
.env
.htpasswd

View File

@ -1,17 +1,29 @@
before_script:
- "pip install -r requirements.txt"
- "pip install pylint"
- "curl -sL https://deb.nodesource.com/setup_10.x | bash -"
- "curl -sL https://deb.nodesource.com/setup_6.x | bash -"
- "apt-get install -y nodejs jq"
- "npm install"
lint:
lint:2.7:
image: "python:2.7"
stage: "test"
script:
- "hooks/pre-commit"
lint:3:
image: "python:3"
stage: "test"
script:
- "hooks/pre-commit"
test:
test:2.7:
image: "python:2.7"
stage: "test"
script:
- python -m flatisfy init-config | jq '.constraints.default.house_types = ["APART"] | .constraints.default.type = "RENT" | .constraints.default.postal_codes = ["75014"]' > /tmp/config.json
- python -m flatisfy test --config /tmp/config.json
test:3:
image: "python:3"
stage: "test"
script:

View File

@ -1,6 +0,0 @@
{
"recommendations": [
"mtxr.sqltools",
"mtxr.sqltools-driver-sqlite"
]
}

15
.vscode/settings.json vendored
View File

@ -1,15 +0,0 @@
{
"cSpell.words": [
"woob",
"flatisfy"
],
"sqltools.useNodeRuntime": true,
"sqltools.connections": [
{
"previewLimit": 50,
"driver": "SQLite",
"name": "flatisfy",
"database": "${workspaceFolder:flatisfy}/data/flatisfy.db"
}
]
}

View File

@ -53,9 +53,9 @@ locale.
Thanks!
## Adding support for a new Woob backend
## Adding support for a new Weboob backend
To enable a new Woob `CapHousing` backend in Flatisfy, you should add it to
To enable a new Weboob `CapHousing` backend in Flatisfy, you should add it to
the list of available backends in
[flatisfy/fetch.py#L69-70](https://git.phyks.me/Phyks/flatisfy/blob/master/flatisfy/fetch.py#L69-70)
and update the list of `BACKEND_PRECEDENCES` for deduplication in
@ -77,20 +77,4 @@ If you want to add new data files, especially for public transportation stops
3. Write a preprocessing function in `flatisfy/data_files/__init__.py`. You
can have a look at the existing functions for a model.
## Adding new migrations
If you want to change the database schema, you should create a matching
migration. Here is the way to do it correctly:
1. First, edit the `flatisfy/models` files to create / remove the required
fields. If you create a new database from scratch, these are the files
which will be used.
2. Then, run `alembic revision -m "Some description"` in the root of the git
repo to create a new migration.
3. Finally, edit the newly created migration file under the `migrations/`
folder to add the required code to alter the database (both upgrade and
downgrade).
Thanks!

View File

@ -19,7 +19,7 @@ and it is working fine :)
<img src="doc/img/home.png" width="45%"/> <img src="doc/img/home2.png" width="45%"/>
It uses [woob](https://gitlab.com/woob/woob/) to get all the housing posts on most of
It uses [Weboob](http://weboob.org/) to get all the housing posts on most of
the websites offering housings posts, and then offers a bunch of pipelines to
filter and deduplicate the fetched housings.
@ -116,9 +116,7 @@ Feel free to open issues. An IRC channel is available at [irc://irc.freenode.net
## Thanks
* [Woob](https://gitlab.com/woob/woob/). Note that this is actually the only and best
software out there to scrape housing posts online. Using it in Flatisfy does
not mean the authors of Flatisfy endorse Woob authors' views.
* [Weboob](http://weboob.org/)
* The OpenData providers listed above!
* Navitia for their really cool public transportation API.
* A lots of Python modules, required for this script (see `requirements.txt`).

View File

@ -1,74 +0,0 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = migrations
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# timezone to use when rendering the date
# within the migration file as well as the filename.
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
#truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; this defaults
# to migrations/versions. When using multiple version
# directories, initial revisions must be specified with --version-path
# version_locations = %(here)s/bar %(here)s/bat migrations/versions
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = sqlite:///data/flatisfy.db
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@ -2,35 +2,30 @@ Getting started
===============
## Dependency on Woob
## Dependency on Weboob
**Important**: Flatisfy relies on [Woob](https://gitlab.com/woob/woob/) to fetch
housing posts from housing websites.
**Important**: Flatisfy relies on [Weboob](http://weboob.org/) to fetch
housing posts from housing websites. Then, you should install the [`devel`
branch](https://git.weboob.org/weboob/devel/) and update it regularly,
especially if Flatisfy suddenly stops fetching housing posts.
If you `pip install -r requirements.txt` it will install the latest
development version of [Woob](https://gitlab.com/woob/woob/) and the
[Woob modules](https://gitlab.com/woob/modules/), which should be the
development version of [Weboob](https://git.weboob.org/weboob/devel/) and the
[Weboob modules](https://git.weboob.org/weboob/modules/), which should be the
best version available out there. You should update these packages regularly,
as they evolve quickly.
Woob is made of two parts: a core and modules (which is the actual code
Weboob is made of two parts: a core and modules (which is the actual code
fetching data from websites). Modules tend to break often and are then updated
often, you should keep them up to date. This can be done by installing and
upgrading the packages listed in the `requirements.txt` and using the default
configuration.
often, you should keep them up to date. This can be done by installing the
`weboob-modules` package listed in the `requirements.txt` and using the
default configuration.
This is a safe default configuration. However, a better option is usually to
clone [Woob git repo](https://gitlab.com/woob/woob/) somewhere, on
clone [Weboob git repo](https://git.weboob.org/weboob/devel/) somewhere, on
your disk, to point `modules_path` configuration option to
`path_to_woob_git/modules` (see the configuration section below) and to run
a `git pull; python setup.py install` in the Woob git repo often.
A copy of the Woob modules is available in the `modules` directory at the
root of this repository, you can use `"modules_path": "/path/to/flatisfy/modules"` to use them.
This copy may or may not be more up to date than the current state of official
Woob modules. Some changes are made there, which are not backported
upstream. Woob official modules are not synced in the `modules` folder on a
regular basis, so try both and see which ones match your needs! :)
`path_to_weboob_git/modules` (see the configuration section below) and to run
a `git pull; python setup.py install` in the Weboob git repo often.
## TL;DR
@ -78,11 +73,7 @@ The available commands are:
_Note:_ Fetching flats can be quite long and take up to a few minutes. This
should be better optimized. To get a verbose output and have an hint about the
progress, use the `-v` argument. It can remain stuck at "Loading flats for
constraint XXX...", which simply means it is fetching flats (using Woob
under the hood) and this step can be super long if there are lots of flats to
fetch. If this happens to you, you can set `max_entries` in your config to
limit the number of flats to fetch.
progress, use the `-v` argument.
### Common arguments
@ -117,15 +108,12 @@ List of configuration options:
means that it will store the database in the default location, in
`data_directory`.
* `navitia_api_key` is an API token for [Navitia](https://www.navitia.io/)
which is required to compute travel times for `PUBLIC_TRANSPORT` mode.
* `mapbox_api_key` is an API token for [Mapbox](http://mapbox.com/)
which is required to compute travel times for `WALK`, `BIKE` and `CAR`
modes.
* `modules_path` is the path to the Woob modules. It can be `null` if you
want Woob to use the locally installed [Woob
modules](https://gitlab.com/woob/modules/), which you should install
which is required to compute travel times.
* `modules_path` is the path to the Weboob modules. It can be `null` if you
want Weboob to use the locally installed [Weboob
modules](https://git.weboob.org/weboob/modules), which you should install
yourself. This is the default value. If it is a string, it should be an
absolute path to the folder containing Woob modules.
absolute path to the folder containing Weboob modules.
* `port` is the port on which the development webserver should be
listening (default to `8080`).
* `host` is the host on which the development webserver should be listening
@ -133,8 +121,8 @@ List of configuration options:
* `webserver` is a server to use instead of the default Bottle built-in
webserver, see [Bottle deployment
doc](http://bottlepy.org/docs/dev/deployment.html).
* `backends` is a list of Woob backends to enable. It defaults to any
available and supported Woob backend.
* `backends` is a list of Weboob backends to enable. It defaults to any
available and supported Weboob backend.
* `store_personal_data` is a boolean indicated whether or not Flatisfy should
fetch personal data from housing posts and store them in database. Such
personal data include contact phone number for instance. By default,
@ -144,10 +132,6 @@ List of configuration options:
`1500`). This is useful to avoid false-positive.
* `duplicate_threshold` is the minimum score in the deep duplicate detection
step to consider two flats as being duplicates (defaults to `15`).
* `serve_images_locally` lets you download all the images from the housings
websites when importing the posts. Then, all your Flatisfy works standalone,
serving the local copy of the images instead of fetching the images from the
remote websites every time you look through the fetched housing posts.
_Note:_ In production, you can either use the `serve` command with a reliable
webserver instead of the default Bottle webserver (specifying a `webserver`
@ -172,14 +156,14 @@ under the `constraints` key. The available constraints are:
* `postal_codes` (as strings) is a list of postal codes. You should include any postal code
you want, and especially the postal codes close to the precise location you
want.
* `time_to` is a dictionary of places to compute travel time to them.
* `time_to` is a dictionary of places to compute travel time to them (using
public transport, relies on [Navitia API](http://navitia.io/)).
Typically,
```
"time_to": {
"foobar": {
"gps": [LAT, LNG],
"mode": A transport mode,
"time": [min, max]
}
}
@ -188,25 +172,13 @@ under the `constraints` key. The available constraints are:
means that the housings must be between the `min` and `max` bounds (possibly
`null`) from the place identified by the GPS coordinates `LAT` and `LNG`
(latitude and longitude), and we call this place `foobar` in human-readable
form. `mode` should be either `PUBLIC_TRANSPORT`, `WALK`, `BIKE` or `CAR`.
Beware that `time` constraints are in **seconds**. You should take
some margin as the travel time computation is done with found nearby public
transport stations, which is only a rough estimate of the flat position. For
`PUBLIC_TRANSPORT` the travel time is computed assuming a route the next
Monday at 8am.
form. Beware that `time` constraints are in **seconds**.
* `minimum_nb_photos` lets you filter out posts with less than this number of
photos.
* `description_should_contain` lets you specify a list of terms that should
be present in the posts descriptions. Typically, if you expect "parking" to
be in all the posts Flatisfy fetches for you, you can set
`description_should_contain: ["parking"]`. You can also use list of terms
which acts as an "or" operation. For example, if you are looking for a flat
with a parking and with either a balcony or a terrace, you can use
`description_should_contain: ["parking", ["balcony", "terrace"]]`
* `description_should_not_contain` lets you specify a list of terms that should
never occur in the posts descriptions. Typically, if you wish to avoid
"coloc" in the posts Flatisfy fetches for you, you can set
`description_should_not_contain: ["coloc"]`.
`description_should_contain: ["parking"]`.
You can think of constraints as "a set of criterias to filter out flats". You
@ -219,30 +191,4 @@ provided that you name each of them uniquely.
If you want to build the web assets, you can use `npm run build:dev`
(respectively `npm run watch:dev` to build continuously and monitor changes in
source files). You can use `npm run build:prod` (`npm run watch:prod`) to do
the same in production mode (main difference is minification of generated
source code).
**Note**: If `npm run build:prod` fails, you can fall back to `npm run
build:dev` safely.
## Upgrading
To update the app, you can simply `git pull` the latest version. The database
schema might change from time to time. Here is how to update it automatically:
* First, edit the `alembic.ini` file and ensure the `sqlalchemy.url` entry
points to the database URI you are actually using for Flatisfy.
* Then, run `alembic upgrade head` to run the required migrations.
## Misc
### Other tools more or less connected with Flatisfy
+ [ZipAround](https://github.com/guix77/ziparound) generates a list of ZIP codes centered on a city name, within a radius of N kilometers and within a certain travel time by car (France only). You can invoke it with:
```sh
npm ziparound
# or alternatively
npm ziparound --code 75001 --distance 3
```
the same in production mode (with minification etc).

View File

@ -20,6 +20,9 @@ virtualenv .env && source .env/bin/activate
# Install required Python modules
pip install -r requirements.txt
# Clone and install weboob
git clone https://git.weboob.org/weboob/devel weboob && cd weboob && python setup.py install && cd ..
# Install required JS libraries and build the webapp
npm install && npm run build:prod
@ -39,19 +42,13 @@ python -m flatisfy build-data --config config/config.json -v
python -m flatisfy import --config config/config.json -v
```
_Note 1_: In the config, you should set `data_directory` to the absolute path of
_Note_: In the config, you should set `data_directory` to the absolute path of
the `data` directory created below. This directory should be writable by the
user running Flatisfy. You should also set `modules_path` to the absolute path
to the `modules` folder under the previous `woob` clone. Finally, the last
to the `modules` folder under the previous `weboob` clone. Finally, the last
`import` command can be `cron`-tasked to automatically fetch available
housings posts periodically.
_Note 2_: As of 2019-03-13, building the webapp requires libpng-dev to be able to build pngquant-bin. On Debian Stretch (tested with Node v11.11.0):
sudo apt install libpng-dev
_Note 3_: If `npm run build:prod` fails, you can fall back to `npm run
build:dev` safely.
## Use an alternative Bottle backend (production)
@ -224,26 +221,3 @@ setup. You should also set the `.htpasswd` file with users and credentials.
_Note_: This vhost is really minimalistic and you should adapt it to your
setup, enforce SSL ciphers for increased security and do such good practices
things.
### If database is in read only
In the case of you have a "flatisfy" user, and another user runs the webserver, for instance "www-data", you should have problems with the webapp reading, but not writing, the database. Workaround (Debian):
Add www-data in flatisfy group:
sudo usermod -a -G flatisfy www-data
Chmod data dir + DB file:
sudo chmod 775 data
sudo chmod 664 data/flatisfy.db
Edit /etc/uwsgi/apps-available/flatisfy.ini and add:
chmod-socket = 664
Restart:
```bash
systemctl restart uwsgi
```

View File

@ -20,7 +20,7 @@ docker build -t phyks/flatisfy .
mkdir flatisfy
cd flatisfy
FLATISFY_VOLUME=$(pwd)
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"
docker run -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"
```
@ -44,7 +44,7 @@ Your Flatisfy instance is now available at `localhost:8080`!
To fetch new housing posts, you should manually call
```
docker run --rm -it -e LOCAL_USER_ID=`id -u` -v $FLATISFY_VOLUME:/flatisfy phyks/flatisfy /home/user/fetch.sh
docker run -it -e LOCAL_USER_ID=`id -u` -v $FLATISFY_VOLUME:/flatisfy phyks/flatisfy /home/user/fetch.sh
```
This can be done easily in a crontask on your host system, to run it typically

View File

@ -15,22 +15,3 @@ references to all the other "duplicate" posts. These latter duplicate posts
are then simply marked as such and never shown anymore.
All origins are kept in a `urls` field in the remaining post.
## Flatisfy seems to be stuck fetching posts
Fetching posts can be a long process, depending on your criterias. Run the
import command with `-v` argument to get a more verbose output and check
things are indeed happening. If fetching the flats is still too long, try to
set `max_entries` in your config to limit the number of posts fetched.
## Docker image does not start the webserver at first start?
When you launch the Docker image, it first updates Woob and fetches the
housing posts matching your criterias. The webserver is only started once this
is done. As fetching housing posts can take a bit of time (up to 10 minutes),
the webserver will not be available right away.
Once everything is ready, you should see a log message in the console running
the Docker image, confirming you that webserver is up and running.

View File

@ -18,8 +18,7 @@
import os
import sys
sys.path.insert(0, os.path.abspath(".."))
sys.path.insert(0, os.path.abspath('..'))
# -- General configuration ------------------------------------------------
@ -31,19 +30,19 @@ sys.path.insert(0, os.path.abspath(".."))
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.viewcode",
'sphinx.ext.autodoc',
'sphinx.ext.viewcode',
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ["_templates"]
templates_path = ['_templates']
# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
#
source_suffix = [".rst", ".md"]
source_suffix = ['.rst', '.md']
source_parsers = {
".md": "recommonmark.parser.CommonMarkParser",
'.md': 'recommonmark.parser.CommonMarkParser',
}
# The encoding of source files.
@ -51,21 +50,21 @@ source_parsers = {
# source_encoding = 'utf-8-sig'
# The master toctree document.
master_doc = "index"
master_doc = 'index'
# General information about the project.
project = u"Flatisfy"
copyright = u"2017, Phyks (Lucas Verney)"
author = u"Phyks (Lucas Verney)"
project = u'Flatisfy'
copyright = u'2017, Phyks (Lucas Verney)'
author = u'Phyks (Lucas Verney)'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = u"0.1"
version = u'0.1'
# The full version, including alpha/beta/rc tags.
release = u"0.1"
release = u'0.1'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
@ -86,7 +85,7 @@ language = None
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This patterns also effect to html_static_path and html_extra_path
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
# The reST default role (used for this markup: `text`) to use for all
# documents.
@ -108,7 +107,7 @@ exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
# show_authors = False
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = "sphinx"
pygments_style = 'sphinx'
# A list of ignored prefixes for module index sorting.
# modindex_common_prefix = []
@ -125,7 +124,7 @@ todo_include_todos = False
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = "classic"
html_theme = 'classic'
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
@ -159,7 +158,7 @@ html_theme = "classic"
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ["_static"]
html_static_path = ['_static']
# Add any extra paths that contain custom files (such as robots.txt or
# .htaccess) here, relative to this directory. These files are copied
@ -239,7 +238,7 @@ html_static_path = ["_static"]
# html_search_scorer = 'scorer.js'
# Output file base name for HTML help builder.
htmlhelp_basename = "Flatisfydoc"
htmlhelp_basename = 'Flatisfydoc'
# -- Options for LaTeX output ---------------------------------------------
@ -247,12 +246,15 @@ latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#
# 'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#
# 'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#
# 'preamble': '',
# Latex figure (float) alignment
#
# 'figure_align': 'htbp',
@ -262,13 +264,8 @@ latex_elements = {
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
(
master_doc,
"Flatisfy.tex",
u"Flatisfy Documentation",
u"Phyks (Lucas Verney)",
"manual",
),
(master_doc, 'Flatisfy.tex', u'Flatisfy Documentation',
u'Phyks (Lucas Verney)', 'manual'),
]
# The name of an image file (relative to this directory) to place at the top of
@ -308,7 +305,10 @@ latex_documents = [
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [(master_doc, "flatisfy", u"Flatisfy Documentation", [author], 1)]
man_pages = [
(master_doc, 'flatisfy', u'Flatisfy Documentation',
[author], 1)
]
# If true, show URL addresses after external links.
#
@ -321,15 +321,9 @@ man_pages = [(master_doc, "flatisfy", u"Flatisfy Documentation", [author], 1)]
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
(
master_doc,
"Flatisfy",
u"Flatisfy Documentation",
author,
"Flatisfy",
"One line description of project.",
"Miscellaneous",
),
(master_doc, 'Flatisfy', u'Flatisfy Documentation',
author, 'Flatisfy', 'One line description of project.',
'Miscellaneous'),
]
# Documents to append as an appendix to all manuals.

View File

@ -1,4 +1,4 @@
FROM python:3
FROM python:2
MAINTAINER Phyks <phyks@phyks.me>
# Setup layout.
@ -12,27 +12,39 @@ RUN apt-get update && \
# Install latest pip and python dependencies.
RUN pip install -U setuptools && \
pip install html2text simplejson beautifulsoup4
pip install html2text simplejson BeautifulSoup
# Install node.js.
RUN curl -sL https://deb.nodesource.com/setup_10.x | bash - \
RUN curl -sL https://deb.nodesource.com/setup_8.x | bash - \
&& apt-get install -y nodejs
# Install weboob's code itself.
RUN git clone --depth 1 https://git.weboob.org/weboob/devel /home/user/weboob \
&& cd /home/user/weboob \
&& pip install .
RUN mkdir -p /flatisfy/data
VOLUME /flatisfy
COPY ./*.sh /home/user/
# Install Flatisfy, set up directories and permissions.
# Install Flatisfy.
RUN cd /home/user \
&& git clone https://framagit.org/phyks/Flatisfy.git/ ./app \
&& git clone https://git.phyks.me/Phyks/flatisfy/ ./app \
&& cd ./app \
&& pip install -r requirements.txt \
&& npm install \
&& npm run build:dev \
&& mkdir -p /home/user/.local/share/flatisfy \
&& chown user:user -R /home/user \
&& chmod +x /home/user/*.sh
&& npm run build:prod
RUN chown user:user -R /home/user
RUN mkdir -p /home/user/.local/share/flatisfy
COPY ./run.sh /home/user/run.sh
RUN chmod +x /home/user/run.sh
COPY ./entrypoint.sh /home/user/entrypoint.sh
RUN chmod +x /home/user/entrypoint.sh
COPY ./update_weboob.sh /home/user/update_weboob.sh
RUN chmod +x /home/user/update_weboob.sh
# Run server.
EXPOSE 8080

View File

@ -1,12 +0,0 @@
version: "3"
services:
app:
build: .
# image: phyks/flatisfy
environment:
- LOCAL_USER_ID=1000
volumes:
- ./data:/flatisfy
ports:
- "8080:8080"
working_dir: /home/user/app

View File

@ -14,4 +14,7 @@ echo "[ENTRYPOINT] Setting fake values for git config..."
git config --global user.email flatisfy@example.com
git config --global user.name "Flatisfy Root"
echo "Update Weboob..."
/home/user/update_weboob.sh
exec su user -c "$@"

View File

@ -1,6 +0,0 @@
#!/bin/bash
set -e
echo "Fetching new housing posts..."
cd /home/user/app
python -m flatisfy import -v --config /flatisfy/config.json

7
docker/update_weboob.sh Normal file
View File

@ -0,0 +1,7 @@
#!/bin/bash
set -e
echo "Updating Weboob..."
cd /home/user/weboob
git pull
pip install --upgrade .

View File

@ -17,7 +17,6 @@ from flatisfy import data
from flatisfy import fetch
from flatisfy import tools
from flatisfy import tests
# pylint: enable=locally-disabled,wrong-import-position
@ -28,47 +27,68 @@ def parse_args(argv=None):
"""
Create parser and parse arguments.
"""
parser = argparse.ArgumentParser(prog="Flatisfy", description="Find the perfect flat.")
parser = argparse.ArgumentParser(prog="Flatisfy",
description="Find the perfect flat.")
# Parent parser containing arguments common to any subcommand
parent_parser = argparse.ArgumentParser(add_help=False)
parent_parser.add_argument("--data-dir", help="Location of Flatisfy data directory.")
parent_parser.add_argument("--config", help="Configuration file to use.")
parent_parser.add_argument(
"--passes",
choices=[0, 1, 2, 3],
type=int,
help="Number of passes to do on the filtered data.",
"--data-dir",
help="Location of Flatisfy data directory."
)
parent_parser.add_argument("--max-entries", type=int, help="Maximum number of entries to fetch.")
parent_parser.add_argument("-v", "--verbose", action="store_true", help="Verbose logging output.")
parent_parser.add_argument("-vv", action="store_true", help="Debug logging output.")
parent_parser.add_argument(
"--constraints",
type=str,
help="Comma-separated list of constraints to consider.",
"--config",
help="Configuration file to use."
)
parent_parser.add_argument(
"--passes", choices=[0, 1, 2, 3], type=int,
help="Number of passes to do on the filtered data."
)
parent_parser.add_argument(
"--max-entries", type=int,
help="Maximum number of entries to fetch."
)
parent_parser.add_argument(
"-v", "--verbose", action="store_true",
help="Verbose logging output."
)
parent_parser.add_argument(
"-vv", action="store_true",
help="Debug logging output."
)
parent_parser.add_argument(
"--constraints", type=str,
help="Comma-separated list of constraints to consider."
)
# Subcommands
subparsers = parser.add_subparsers(dest="cmd", help="Available subcommands")
subparsers = parser.add_subparsers(
dest="cmd", help="Available subcommands"
)
# Build data subcommand
subparsers.add_parser("build-data", parents=[parent_parser], help="Build necessary data")
subparsers.add_parser(
"build-data", parents=[parent_parser],
help="Build necessary data"
)
# Init config subcommand
parser_init_config = subparsers.add_parser(
"init-config", parents=[parent_parser], help="Initialize empty configuration."
"init-config", parents=[parent_parser],
help="Initialize empty configuration."
)
parser_init_config.add_argument(
"output", nargs="?", help="Output config file. Use '-' for stdout."
)
parser_init_config.add_argument("output", nargs="?", help="Output config file. Use '-' for stdout.")
# Fetch subcommand parser
subparsers.add_parser("fetch", parents=[parent_parser], help="Fetch housings posts")
subparsers.add_parser("fetch", parents=[parent_parser],
help="Fetch housings posts")
# Filter subcommand parser
parser_filter = subparsers.add_parser(
"filter",
parents=[parent_parser],
help="Filter housings posts according to constraints in config.",
"filter", parents=[parent_parser],
help="Filter housings posts according to constraints in config."
)
parser_filter.add_argument(
"--input",
@ -77,29 +97,28 @@ def parse_args(argv=None):
"no additional fetching of infos is done, and the script outputs "
"a filtered JSON dump on stdout. If not provided, update status "
"of the flats in the database."
),
)
)
# Import subcommand parser
import_filter = subparsers.add_parser("import", parents=[parent_parser], help="Import housing posts in database.")
import_filter.add_argument(
"--new-only",
action="store_true",
help=("Download new housing posts only but do not refresh existing ones"),
)
subparsers.add_parser("import", parents=[parent_parser],
help="Import housing posts in database.")
# Purge subcommand parser
subparsers.add_parser("purge", parents=[parent_parser], help="Purge database.")
subparsers.add_parser("purge", parents=[parent_parser],
help="Purge database.")
# Serve subcommand parser
parser_serve = subparsers.add_parser("serve", parents=[parent_parser], help="Serve the web app.")
parser_serve = subparsers.add_parser("serve", parents=[parent_parser],
help="Serve the web app.")
parser_serve.add_argument("--port", type=int, help="Port to bind to.")
parser_serve.add_argument("--host", help="Host to listen on.")
# Test subcommand parser
subparsers.add_parser("test", parents=[parent_parser], help="Unit testing.")
subparsers.add_parser("test", parents=[parent_parser],
help="Unit testing.")
return parser, parser.parse_args(argv)
return parser.parse_args(argv)
def main():
@ -108,30 +127,25 @@ def main():
"""
# pylint: disable=locally-disabled,too-many-branches
# Parse arguments
parser, args = parse_args()
args = parse_args()
# Set logger
if getattr(args, 'vv', False):
logging.getLogger("").setLevel(logging.DEBUG)
logging.getLogger("titlecase").setLevel(logging.INFO)
logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
elif getattr(args, 'verbose', False):
logging.getLogger("").setLevel(logging.INFO)
if args.vv:
logging.getLogger('').setLevel(logging.DEBUG)
logging.getLogger('sqlalchemy.engine').setLevel(logging.DEBUG)
elif args.verbose:
logging.getLogger('').setLevel(logging.INFO)
# sqlalchemy INFO level is way too loud, just stick with WARNING
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
logging.getLogger('sqlalchemy.engine').setLevel(logging.WARNING)
else:
logging.getLogger("").setLevel(logging.WARNING)
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
logging.getLogger('').setLevel(logging.WARNING)
logging.getLogger('sqlalchemy.engine').setLevel(logging.WARNING)
# Init-config command
if args.cmd == "init-config":
flatisfy.config.init_config(args.output)
sys.exit(0)
else:
if not args.cmd:
parser.print_help()
sys.exit(0)
# Load config
if args.cmd == "build-data":
# Data not yet built, do not use it in config checks
@ -139,11 +153,9 @@ def main():
else:
config = flatisfy.config.load_config(args, check_with_data=True)
if config is None:
LOGGER.error(
"Invalid configuration. Exiting. "
LOGGER.error("Invalid configuration. Exiting. "
"Run init-config before if this is the first time "
"you run Flatisfy."
)
"you run Flatisfy.")
sys.exit(1)
# Purge command
@ -159,11 +171,18 @@ def main():
if args.cmd == "fetch":
# Fetch and filter flats list
fetched_flats = fetch.fetch_flats(config)
fetched_flats = cmds.filter_fetched_flats(config, fetched_flats=fetched_flats, fetch_details=True)
fetched_flats = cmds.filter_fetched_flats(config,
fetched_flats=fetched_flats,
fetch_details=True)
# Sort by cost
fetched_flats = {k: tools.sort_list_of_dicts_by(v["new"], "cost") for k, v in fetched_flats.items()}
fetched_flats = {
k: tools.sort_list_of_dicts_by(v["new"], "cost")
for k, v in fetched_flats.items()
}
print(tools.pretty_json(fetched_flats))
print(
tools.pretty_json(fetched_flats)
)
return
# Filter command
elif args.cmd == "filter":
@ -171,19 +190,28 @@ def main():
if args.input:
fetched_flats = fetch.load_flats_from_file(args.input, config)
fetched_flats = cmds.filter_fetched_flats(config, fetched_flats=fetched_flats, fetch_details=False)
fetched_flats = cmds.filter_fetched_flats(
config,
fetched_flats=fetched_flats,
fetch_details=False
)
# Sort by cost
fetched_flats = {k: tools.sort_list_of_dicts_by(v["new"], "cost") for k, v in fetched_flats.items()}
fetched_flats = {
k: tools.sort_list_of_dicts_by(v["new"], "cost")
for k, v in fetched_flats.items()
}
# Output to stdout
print(tools.pretty_json(fetched_flats))
print(
tools.pretty_json(fetched_flats)
)
else:
cmds.import_and_filter(config, load_from_db=True)
return
# Import command
elif args.cmd == "import":
cmds.import_and_filter(config, load_from_db=False, new_only=args.new_only)
cmds.import_and_filter(config, load_from_db=False)
return
# Serve command
elif args.cmd == "serve":

View File

@ -6,7 +6,6 @@ from __future__ import absolute_import, print_function, unicode_literals
import collections
import logging
import os
import flatisfy.filters
from flatisfy import database
@ -18,23 +17,21 @@ from flatisfy import fetch
from flatisfy import tools
from flatisfy.filters import metadata
from flatisfy.web import app as web_app
import time
from ratelimit.exception import RateLimitException
LOGGER = logging.getLogger(__name__)
def filter_flats_list(config, constraint_name, flats_list, fetch_details=True, past_flats=None):
def filter_flats_list(config, constraint_name, flats_list, fetch_details=True):
"""
Filter the available flats list. Then, filter it according to criteria.
:param config: A config dict.
:param constraint_name: The constraint name that the ``flats_list`` should
satisfy.
:param flats_list: The initial list of flat objects to filter.
:param fetch_details: Whether additional details should be fetched between
the two passes.
:param past_flats: The list of already fetched flats
:param flats_list: The initial list of flat objects to filter.
:return: A dict mapping flat status and list of flat objects.
"""
# Add the flatisfy metadata entry and prepare the flat objects
@ -46,9 +43,13 @@ def filter_flats_list(config, constraint_name, flats_list, fetch_details=True, p
except KeyError:
LOGGER.error(
"Missing constraint %s. Skipping filtering for these posts.",
constraint_name,
constraint_name
)
return {"new": [], "duplicate": [], "ignored": []}
return {
"new": [],
"duplicate": [],
"ignored": []
}
first_pass_result = collections.defaultdict(list)
second_pass_result = collections.defaultdict(list)
@ -56,55 +57,52 @@ def filter_flats_list(config, constraint_name, flats_list, fetch_details=True, p
# Do a first pass with the available infos to try to remove as much
# unwanted postings as possible
if config["passes"] > 0:
first_pass_result = flatisfy.filters.first_pass(flats_list, constraint, config)
first_pass_result = flatisfy.filters.first_pass(flats_list,
constraint,
config)
else:
first_pass_result["new"] = flats_list
# Load additional infos
if fetch_details:
past_ids = {x["id"]: x for x in past_flats} if past_flats else {}
for i, flat in enumerate(first_pass_result["new"]):
details = None
use_cache = past_ids.get(flat["id"])
if use_cache:
LOGGER.debug("Skipping details download for %s.", flat["id"])
details = use_cache
else:
if flat["id"].split("@")[1] in ["seloger", "leboncoin"]:
try:
details = fetch.fetch_details_rate_limited(config, flat["id"])
except RateLimitException:
time.sleep(60)
details = fetch.fetch_details_rate_limited(config, flat["id"])
else:
details = fetch.fetch_details(config, flat["id"])
first_pass_result["new"][i] = tools.merge_dicts(flat, details)
# Do a second pass to consolidate all the infos we found and make use of
# additional infos
if config["passes"] > 1:
second_pass_result = flatisfy.filters.second_pass(first_pass_result["new"], constraint, config)
second_pass_result = flatisfy.filters.second_pass(
first_pass_result["new"], constraint, config
)
else:
second_pass_result["new"] = first_pass_result["new"]
# Do a third pass to deduplicate better
if config["passes"] > 2:
third_pass_result = flatisfy.filters.third_pass(second_pass_result["new"], config)
third_pass_result = flatisfy.filters.third_pass(
second_pass_result["new"],
config
)
else:
third_pass_result["new"] = second_pass_result["new"]
return {
"new": third_pass_result["new"],
"duplicate": (
first_pass_result["duplicate"] + second_pass_result["duplicate"] + third_pass_result["duplicate"]
first_pass_result["duplicate"] +
second_pass_result["duplicate"] +
third_pass_result["duplicate"]
),
"ignored": (first_pass_result["ignored"] + second_pass_result["ignored"] + third_pass_result["ignored"]),
"ignored": (
first_pass_result["ignored"] +
second_pass_result["ignored"] +
third_pass_result["ignored"]
)
}
def filter_fetched_flats(config, fetched_flats, fetch_details=True, past_flats={}):
def filter_fetched_flats(config, fetched_flats, fetch_details=True):
"""
Filter the available flats list. Then, filter it according to criteria.
@ -121,40 +119,33 @@ def filter_fetched_flats(config, fetched_flats, fetch_details=True, past_flats={
config,
constraint_name,
flats_list,
fetch_details,
past_flats.get(constraint_name, None),
fetch_details
)
return fetched_flats
def import_and_filter(config, load_from_db=False, new_only=False):
def import_and_filter(config, load_from_db=False):
"""
Fetch the available flats list. Then, filter it according to criteria.
Finally, store it in the database.
:param config: A config dict.
:param load_from_db: Whether to load flats from database or fetch them
using Woob.
using Weboob.
:return: ``None``.
"""
# Fetch and filter flats list
past_flats = fetch.load_flats_from_db(config)
if load_from_db:
fetched_flats = past_flats
fetched_flats = fetch.load_flats_from_db(config)
else:
fetched_flats = fetch.fetch_flats(config)
# Do not fetch additional details if we loaded data from the db.
flats_by_status = filter_fetched_flats(
config,
fetched_flats=fetched_flats,
fetch_details=(not load_from_db),
past_flats=past_flats if new_only else {},
)
flats_by_status = filter_fetched_flats(config, fetched_flats=fetched_flats,
fetch_details=(not load_from_db))
# Create database connection
get_session = database.init_db(config["database"], config["search_index"])
new_flats = []
result = []
LOGGER.info("Merging fetched flats in database...")
# Flatten the flats_by_status dict
@ -164,14 +155,12 @@ def import_and_filter(config, load_from_db=False, new_only=False):
flatten_flats_by_status[status].extend(flats_list)
with get_session() as session:
# Set is_expired to true for all existing flats.
# This will be set back to false if we find them during importing.
for flat in session.query(flat_model.Flat).all():
flat.is_expired = True
for status, flats_list in flatten_flats_by_status.items():
# Build SQLAlchemy Flat model objects for every available flat
flats_objects = {flat_dict["id"]: flat_model.Flat.from_dict(flat_dict) for flat_dict in flats_list}
flats_objects = {
flat_dict["id"]: flat_model.Flat.from_dict(flat_dict)
for flat_dict in flats_list
}
if flats_objects:
# If there are some flats, try to merge them with the ones in
@ -184,13 +173,11 @@ def import_and_filter(config, load_from_db=False, new_only=False):
# status if the user defined it
flat_object = flats_objects[each.id]
if each.status in flat_model.AUTOMATED_STATUSES:
flat_object.status = getattr(flat_model.FlatStatus, status)
flat_object.status = getattr(
flat_model.FlatStatus, status
)
else:
flat_object.status = each.status
# Every flat we fetched isn't expired
flat_object.is_expired = False
# For each flat already in the db, merge it (UPDATE)
# instead of adding it
session.merge(flats_objects.pop(each.id))
@ -201,22 +188,13 @@ def import_and_filter(config, load_from_db=False, new_only=False):
flat.status = getattr(flat_model.FlatStatus, status)
if flat.status == flat_model.FlatStatus.new:
new_flats.append(flat)
result.append(flat.id)
session.add_all(flats_objects.values())
if config["send_email"]:
email.send_notification(config, new_flats)
LOGGER.info(f"Found {len(result)} new flats.")
# Touch a file to indicate last update timestamp
ts_file = os.path.join(config["data_directory"], "timestamp")
with open(ts_file, "w"):
os.utime(ts_file, None)
LOGGER.info("Done!")
return result
def purge_db(config):
@ -256,5 +234,4 @@ def serve(config):
# standard logging
server = web_app.QuietWSGIRefServer
print("Launching web viewer running on http://%s:%s" % (config["host"], config["port"]))
app.run(host=config["host"], port=config["port"], server=server)

View File

@ -15,17 +15,13 @@ import sys
import traceback
import appdirs
from woob.capabilities.housing import POSTS_TYPES, HOUSE_TYPES
from weboob.capabilities.housing import POSTS_TYPES, HOUSE_TYPES
from flatisfy import data
from flatisfy import tools
from flatisfy.constants import TimeToModes
from flatisfy.models.postal_code import PostalCode
DIRPATH = os.path.dirname(os.path.realpath(__file__))
# Default configuration
DEFAULT_CONFIG = {
# Constraints to match
@ -35,23 +31,14 @@ DEFAULT_CONFIG = {
"house_types": [], # List of house types, must be in APART, HOUSE,
# PARKING, LAND, OTHER or UNKNOWN
"postal_codes": [], # List of postal codes
"insees": [], # List of postal codes
"area": (None, None), # (min, max) in m^2
"cost": (None, None), # (min, max) in currency unit
"rooms": (None, None), # (min, max)
"bedrooms": (None, None), # (min, max)
"minimum_nb_photos": None, # min number of photos
"description_should_contain": [], # list of terms (str) or list
# (acting as an or)
"description_should_not_contain": [
"vendu",
"Vendu",
"VENDU",
"recherche",
],
"description_should_contain": [], # list of terms
"time_to": {} # Dict mapping names to {"gps": [lat, lng],
# "time": (min, max),
# "mode": Valid mode }
# "time": (min, max) }
# Time is in seconds
}
},
@ -69,16 +56,16 @@ DEFAULT_CONFIG = {
"serve_images_locally": True,
# Navitia API key
"navitia_api_key": None,
# Mapbox API key
"mapbox_api_key": None,
# Number of filtering passes to run
"passes": 3,
# Maximum number of entries to fetch
"max_entries": None,
# Directory in wich data will be put. ``None`` is XDG default location.
"data_directory": None,
# Path to the modules directory containing all Woob modules.
"modules_path": os.path.join(DIRPATH, '..', 'modules'),
# Path to the modules directory containing all Weboob modules. ``None`` if
# ``weboob_modules`` package is pip-installed, and you want to use
# ``pkgresource`` to automatically find it.
"modules_path": None,
# SQLAlchemy URI to the database to use
"database": None,
# Path to the Whoosh search index file. Use ``None`` to put it in
@ -90,21 +77,17 @@ DEFAULT_CONFIG = {
"host": "127.0.0.1",
# Web server to use to serve the webapp (see Bottle deployment doc)
"webserver": None,
# List of Woob backends to use (default to any backend available)
# List of Weboob backends to use (default to any backend available)
"backends": None,
# Should email notifications be sent?
"send_email": False,
"smtp_server": "localhost",
"smtp_server": 'localhost',
"smtp_port": 25,
"smtp_username": None,
"smtp_password": None,
"smtp_from": "noreply@flatisfy.org",
"smtp_to": [],
"notification_lang": "en",
# The web site url, to be used in email notifications. (doesn't matter
# whether the trailing slash is present or not)
"website_url": "http://127.0.0.1:8080",
"ignore_station": False,
"website_url": "http://127.0.0.1:8080"
}
LOGGER = logging.getLogger(__name__)
@ -119,14 +102,20 @@ def validate_config(config, check_with_data):
check the config values.
:return: ``True`` if the configuration is valid, ``False`` otherwise.
"""
def _check_constraints_bounds(bounds):
"""
Check the bounds for numeric constraints.
"""
assert isinstance(bounds, list)
assert len(bounds) == 2
assert all(x is None or (isinstance(x, (float, int)) and x >= 0) for x in bounds)
assert all(
x is None or
(
isinstance(x, (float, int)) and
x >= 0
)
for x in bounds
)
if bounds[0] is not None and bounds[1] is not None:
assert bounds[1] > bounds[0]
@ -138,9 +127,7 @@ def validate_config(config, check_with_data):
# pylint: disable=locally-disabled,line-too-long
assert config["passes"] in [0, 1, 2, 3]
assert config["max_entries"] is None or (
isinstance(config["max_entries"], int) and config["max_entries"] > 0
) # noqa: E501
assert config["max_entries"] is None or (isinstance(config["max_entries"], int) and config["max_entries"] > 0) # noqa: E501
assert config["data_directory"] is None or isinstance(config["data_directory"], str) # noqa: E501
assert os.path.isdir(config["data_directory"])
@ -157,28 +144,19 @@ def validate_config(config, check_with_data):
assert isinstance(config["send_email"], bool)
assert config["smtp_server"] is None or isinstance(config["smtp_server"], str) # noqa: E501
assert config["smtp_port"] is None or isinstance(config["smtp_port"], int) # noqa: E501
assert config["smtp_username"] is None or isinstance(config["smtp_username"], str) # noqa: E501
assert config["smtp_password"] is None or isinstance(config["smtp_password"], str) # noqa: E501
assert config["smtp_to"] is None or isinstance(config["smtp_to"], list)
assert config["notification_lang"] is None or isinstance(config["notification_lang"], str)
assert isinstance(config["store_personal_data"], bool)
assert isinstance(config["max_distance_housing_station"], (int, float))
assert isinstance(config["duplicate_threshold"], int)
assert isinstance(config["duplicate_image_hash_threshold"], int)
# API keys
assert config["navitia_api_key"] is None or isinstance(config["navitia_api_key"], str) # noqa: E501
assert config["mapbox_api_key"] is None or isinstance(config["mapbox_api_key"], str) # noqa: E501
assert config["ignore_station"] is None or isinstance(config["ignore_station"], bool) # noqa: E501
# Ensure constraints are ok
assert config["constraints"]
for constraint in config["constraints"].values():
assert "type" in constraint
assert isinstance(constraint["type"], str)
assert constraint["type"].upper() in POSTS_TYPES.__members__
assert constraint["type"].upper() in POSTS_TYPES.keys
assert "minimum_nb_photos" in constraint
if constraint["minimum_nb_photos"]:
@ -189,42 +167,26 @@ def validate_config(config, check_with_data):
assert isinstance(constraint["description_should_contain"], list)
if constraint["description_should_contain"]:
for term in constraint["description_should_contain"]:
try:
assert isinstance(term, str)
except AssertionError:
assert isinstance(term, list)
assert all(isinstance(x, str) for x in term)
assert "description_should_not_contain" in constraint
assert isinstance(constraint["description_should_not_contain"], list)
if constraint["description_should_not_contain"]:
for term in constraint["description_should_not_contain"]:
assert isinstance(term, str)
assert "house_types" in constraint
assert constraint["house_types"]
for house_type in constraint["house_types"]:
assert house_type.upper() in HOUSE_TYPES.__members__
assert house_type.upper() in HOUSE_TYPES.keys
assert "postal_codes" in constraint
assert constraint["postal_codes"]
assert all(isinstance(x, str) for x in constraint["postal_codes"])
if "insee_codes" in constraint:
assert constraint["insee_codes"]
assert all(isinstance(x, str) for x in constraint["insee_codes"])
if check_with_data:
# Ensure data is built into db
data.preprocess_data(config, force=False)
# Check postal codes
opendata = data.load_data(PostalCode, constraint, config)
opendata_postal_codes = [x.postal_code for x in opendata]
opendata_insee_codes = [x.insee_code for x in opendata]
opendata_postal_codes = [
x.postal_code
for x in data.load_data(PostalCode, constraint, config)
]
for postal_code in constraint["postal_codes"]:
assert postal_code in opendata_postal_codes # noqa: E501
if "insee_codes" in constraint:
for insee in constraint["insee_codes"]:
assert insee in opendata_insee_codes # noqa: E501
assert "area" in constraint
_check_constraints_bounds(constraint["area"])
@ -247,8 +209,6 @@ def validate_config(config, check_with_data):
assert len(item["gps"]) == 2
assert "time" in item
_check_constraints_bounds(item["time"])
if "mode" in item:
TimeToModes[item["mode"]]
return True
except (AssertionError, KeyError):
@ -277,18 +237,22 @@ def load_config(args=None, check_with_data=True):
config_data.update(json.load(fh))
except (IOError, ValueError) as exc:
LOGGER.error(
"Unable to load configuration from file, using default configuration: %s.",
exc,
"Unable to load configuration from file, "
"using default configuration: %s.",
exc
)
# Overload config with arguments
if args and getattr(args, "passes", None) is not None:
LOGGER.debug("Overloading number of passes from CLI arguments: %d.", args.passes)
LOGGER.debug(
"Overloading number of passes from CLI arguments: %d.",
args.passes
)
config_data["passes"] = args.passes
if args and getattr(args, "max_entries", None) is not None:
LOGGER.debug(
"Overloading maximum number of entries from CLI arguments: %d.",
args.max_entries,
args.max_entries
)
config_data["max_entries"] = args.max_entries
if args and getattr(args, "port", None) is not None:
@ -303,39 +267,49 @@ def load_config(args=None, check_with_data=True):
LOGGER.debug("Overloading data directory from CLI arguments.")
config_data["data_directory"] = args.data_dir
elif config_data["data_directory"] is None:
config_data["data_directory"] = appdirs.user_data_dir("flatisfy", "flatisfy")
LOGGER.debug("Using default XDG data directory: %s.", config_data["data_directory"])
config_data["data_directory"] = appdirs.user_data_dir(
"flatisfy",
"flatisfy"
)
LOGGER.debug("Using default XDG data directory: %s.",
config_data["data_directory"])
if not os.path.isdir(config_data["data_directory"]):
LOGGER.info(
"Creating data directory according to config: %s",
config_data["data_directory"],
)
LOGGER.info("Creating data directory according to config: %s",
config_data["data_directory"])
os.makedirs(config_data["data_directory"])
os.makedirs(os.path.join(config_data["data_directory"], "images"))
if config_data["database"] is None:
config_data["database"] = "sqlite:///" + os.path.join(config_data["data_directory"], "flatisfy.db")
config_data["database"] = "sqlite:///" + os.path.join(
config_data["data_directory"],
"flatisfy.db"
)
if config_data["search_index"] is None:
config_data["search_index"] = os.path.join(config_data["data_directory"], "search_index")
config_data["search_index"] = os.path.join(
config_data["data_directory"],
"search_index"
)
# Handle constraints filtering
if args and getattr(args, "constraints", None) is not None:
LOGGER.info(
(
"Filtering constraints from config according to CLI argument. "
"Using only the following constraints: %s."
),
args.constraints.replace(",", ", "),
("Filtering constraints from config according to CLI argument. "
"Using only the following constraints: %s."),
args.constraints.replace(",", ", ")
)
constraints_filter = args.constraints.split(",")
config_data["constraints"] = {k: v for k, v in config_data["constraints"].items() if k in constraints_filter}
config_data["constraints"] = {
k: v
for k, v in config_data["constraints"].items()
if k in constraints_filter
}
# Sanitize website url
if config_data["website_url"] is not None:
if config_data["website_url"][-1] != "/":
config_data["website_url"] += "/"
if config_data["website_url"][-1] != '/':
config_data["website_url"] += '/'
config_validation = validate_config(config_data, check_with_data)
if config_validation is True:

View File

@ -4,8 +4,6 @@ Constants used across the app.
"""
from __future__ import absolute_import, print_function, unicode_literals
from enum import Enum
# Some backends give more infos than others. Here is the precedence we want to
# use. First is most important one, last is the one that will always be
# considered as less trustable if two backends have similar info about a
@ -16,12 +14,5 @@ BACKENDS_BY_PRECEDENCE = [
"pap",
"leboncoin",
"explorimmo",
"logicimmo",
"logicimmo"
]
class TimeToModes(Enum):
PUBLIC_TRANSPORT = -1
WALK = 1
BIKE = 2
CAR = 3

View File

@ -24,13 +24,11 @@ except ImportError:
try:
from functools32 import lru_cache
except ImportError:
def lru_cache(maxsize=None): # pylint: disable=unused-argument
"""
Identity implementation of ``lru_cache`` for fallback.
"""
return lambda func: func
LOGGER.warning(
"`functools.lru_cache` is not available on your system. Consider "
"installing `functools32` Python module if using Python2 for "
@ -50,7 +48,10 @@ def preprocess_data(config, force=False):
# Check if a build is required
get_session = database.init_db(config["database"], config["search_index"])
with get_session() as session:
is_built = session.query(PublicTransport).count() > 0 and session.query(PostalCode).count() > 0
is_built = (
session.query(PublicTransport).count() > 0 and
session.query(PostalCode).count() > 0
)
if is_built and not force:
# No need to rebuild the database, skip
return False
@ -63,7 +64,9 @@ def preprocess_data(config, force=False):
for preprocess in data_files.PREPROCESSING_FUNCTIONS:
data_objects = preprocess()
if not data_objects:
raise flatisfy.exceptions.DataBuildError("Error with %s." % preprocess.__name__)
raise flatisfy.exceptions.DataBuildError(
"Error with %s." % preprocess.__name__
)
with get_session() as session:
session.add_all(data_objects)
LOGGER.info("Done building data!")
@ -93,7 +96,10 @@ def load_data(model, constraint, config):
# Load data for each area
areas = list(set(areas))
for area in areas:
results.extend(session.query(model).filter(model.area == area).all())
results.extend(
session.query(model)
.filter(model.area == area).all()
)
# Expunge loaded data from the session to be able to use them
# afterwards
session.expunge_all()

View File

@ -16,7 +16,10 @@ from flatisfy.models.postal_code import PostalCode
from flatisfy.models.public_transport import PublicTransport
from flatisfy.tools import normalize_string
if sys.version_info >= (3, 0):
import csv
else:
from backports import csv
LOGGER = logging.getLogger(__name__)
@ -24,8 +27,8 @@ MODULE_DIR = os.path.dirname(os.path.realpath(__file__))
titlecase.set_small_word_list(
# Add French small words
r"l|d|un|une|et|à|a|sur|ou|le|la|de|lès|les|"
+ titlecase.SMALL
r"l|d|un|une|et|à|a|sur|ou|le|la|de|lès|les|" +
titlecase.SMALL
)
TRANSPORT_DATA_FILES = {
@ -33,7 +36,7 @@ TRANSPORT_DATA_FILES = {
"FR-NW": "stops_fr-nw.txt",
"FR-NE": "stops_fr-ne.txt",
"FR-SW": "stops_fr-sw.txt",
"FR-SE": "stops_fr-se.txt",
"FR-SE": "stops_fr-se.txt"
}
@ -51,20 +54,8 @@ def french_postal_codes_to_quarter(postal_code):
# French departements
# Taken from Wikipedia data.
department_to_subdivision = {
"FR-ARA": [
"01",
"03",
"07",
"15",
"26",
"38",
"42",
"43",
"63",
"69",
"73",
"74",
],
"FR-ARA": ["01", "03", "07", "15", "26", "38", "42", "43", "63", "69",
"73", "74"],
"FR-BFC": ["21", "25", "39", "58", "70", "71", "89", "90"],
"FR-BRE": ["22", "29", "35", "44", "56"],
"FR-CVL": ["18", "28", "36", "37", "41", "45"],
@ -73,53 +64,36 @@ def french_postal_codes_to_quarter(postal_code):
"FR-HDF": ["02", "59", "60", "62", "80"],
"FR-IDF": ["75", "77", "78", "91", "92", "93", "94", "95"],
"FR-NOR": ["14", "27", "50", "61", "76"],
"FR-NAQ": [
"16",
"17",
"19",
"23",
"24",
"33",
"40",
"47",
"64",
"79",
"86",
"87",
],
"FR-OCC": [
"09",
"11",
"12",
"30",
"31",
"32",
"34",
"46",
"48",
"65",
"66",
"81",
"82",
],
"FR-NAQ": ["16", "17", "19", "23", "24", "33", "40", "47", "64", "79",
"86", "87"],
"FR-OCC": ["09", "11", "12", "30", "31", "32", "34", "46", "48", "65",
"66", "81", "82"],
"FR-PDL": ["44", "49", "53", "72", "85"],
"FR-PAC": ["04", "05", "06", "13", "83", "84"],
"FR-PAC": ["04", "05", "06", "13", "83", "84"]
}
subdivision_to_quarters = {
"FR-IDF": ["FR-IDF"],
"FR-NW": ["FR-BRE", "FR-CVL", "FR-NOR", "FR-PDL"],
"FR-NE": ["FR-BFC", "FR-GES", "FR-HDF"],
"FR-SE": ["FR-ARA", "FR-COR", "FR-PAC", "FR-OCC"],
"FR-SW": ["FR-NAQ"],
'FR-IDF': ['FR-IDF'],
'FR-NW': ['FR-BRE', 'FR-CVL', 'FR-NOR', 'FR-PDL'],
'FR-NE': ['FR-BFC', 'FR-GES', 'FR-HDF'],
'FR-SE': ['FR-ARA', 'FR-COR', 'FR-PAC', 'FR-OCC'],
'FR-SW': ['FR-NAQ']
}
subdivision = next(
(i for i, departments in department_to_subdivision.items() if departement in departments),
None,
(
i
for i, departments in department_to_subdivision.items()
if departement in departments
),
None
)
return next(
(i for i, subdivisions in subdivision_to_quarters.items() if subdivision in subdivisions),
None,
(
i
for i, subdivisions in subdivision_to_quarters.items()
if subdivision in subdivisions
),
None
)
@ -135,7 +109,9 @@ def _preprocess_laposte():
raw_laposte_data = []
# Load opendata file
try:
with io.open(os.path.join(MODULE_DIR, data_file), "r", encoding="utf-8") as fh:
with io.open(
os.path.join(MODULE_DIR, data_file), "r", encoding='utf-8'
) as fh:
raw_laposte_data = json.load(fh)
except (IOError, ValueError):
LOGGER.error("Invalid raw LaPoste opendata file.")
@ -151,30 +127,31 @@ def _preprocess_laposte():
try:
area = french_postal_codes_to_quarter(fields["code_postal"])
if area is None:
LOGGER.debug(
LOGGER.info(
"No matching area found for postal code %s, skipping it.",
fields["code_postal"],
fields["code_postal"]
)
continue
name = normalize_string(titlecase.titlecase(fields["nom_de_la_commune"]), lowercase=False)
name = normalize_string(
titlecase.titlecase(fields["nom_de_la_commune"]),
lowercase=False
)
if (fields["code_postal"], name) in seen_postal_codes:
continue
seen_postal_codes.append((fields["code_postal"], name))
postal_codes_data.append(
PostalCode(
postal_codes_data.append(PostalCode(
area=area,
postal_code=fields["code_postal"],
insee_code=fields["code_commune_insee"],
name=name,
lat=fields["coordonnees_gps"][0],
lng=fields["coordonnees_gps"][1],
)
)
lng=fields["coordonnees_gps"][1]
))
except KeyError:
LOGGER.debug("Missing data for postal code %s, skipping it.", fields["code_postal"])
LOGGER.info("Missing data for postal code %s, skipping it.",
fields["code_postal"])
return postal_codes_data
@ -190,11 +167,17 @@ def _preprocess_public_transport():
for area, data_file in TRANSPORT_DATA_FILES.items():
LOGGER.info("Building from public transport data %s.", data_file)
try:
with io.open(os.path.join(MODULE_DIR, data_file), "r", encoding="utf-8") as fh:
with io.open(os.path.join(MODULE_DIR, data_file), "r",
encoding='utf-8') as fh:
filereader = csv.reader(fh)
next(filereader, None) # Skip first row (headers)
for row in filereader:
public_transport_data.append(PublicTransport(name=row[2], area=area, lat=row[3], lng=row[4]))
public_transport_data.append(PublicTransport(
name=row[2],
area=area,
lat=row[3],
lng=row[4]
))
except (IOError, IndexError):
LOGGER.error("Invalid raw opendata file: %s.", data_file)
return []
@ -203,4 +186,7 @@ def _preprocess_public_transport():
# List of all the available preprocessing functions. Order can be important.
PREPROCESSING_FUNCTIONS = [_preprocess_laposte, _preprocess_public_transport]
PREPROCESSING_FUNCTIONS = [
_preprocess_laposte,
_preprocess_public_transport
]

File diff suppressed because one or more lines are too long

View File

@ -13,7 +13,12 @@ from sqlalchemy.engine import Engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.exc import OperationalError, SQLAlchemyError
# Import models
import flatisfy.models.postal_code # noqa: F401
import flatisfy.models.public_transport # noqa: F401
import flatisfy.models.flat # noqa: F401
import flatisfy.models.constraint # noqa: F401
from flatisfy.database.base import BASE
from flatisfy.database.whooshalchemy import IndexService
@ -47,7 +52,9 @@ def init_db(database_uri=None, search_db_uri=None):
Session = sessionmaker(bind=engine) # pylint: disable=locally-disabled,invalid-name
if search_db_uri:
index_service = IndexService(whoosh_base=search_db_uri)
index_service = IndexService(
whoosh_base=search_db_uri
)
index_service.register_class(flatisfy.models.flat.Flat)
@contextmanager

View File

@ -1,53 +0,0 @@
# coding: utf-8
"""
This modules implements custom types in SQLAlchemy.
"""
from __future__ import absolute_import, print_function, unicode_literals
import json
import sqlalchemy.types as types
class StringyJSON(types.TypeDecorator):
"""
Stores and retrieves JSON as TEXT for SQLite.
From
https://avacariu.me/articles/2016/compiling-json-as-text-for-sqlite-with-sqlalchemy.
.. note ::
The associated field is immutable. That is, changes to the data
(typically, changing the value of a dict field) will not trigger an
update on the SQL side upon ``commit`` as the reference to the object
will not have been updated. One should force the update by forcing an
update of the reference (by performing a ``copy`` operation on the dict
for instance).
"""
impl = types.TEXT
def process_bind_param(self, value, dialect):
"""
Process the bound param, serialize the object to JSON before saving
into database.
"""
if value is not None:
value = json.dumps(value)
return value
def process_result_value(self, value, dialect):
"""
Process the value fetched from the database, deserialize the JSON
string before returning the object.
"""
if value is not None:
value = json.loads(value)
return value
# TypeEngine.with_variant says "use StringyJSON instead when
# connecting to 'sqlite'"
# pylint: disable=locally-disabled,invalid-name
MagicJSON = types.JSON().with_variant(StringyJSON, "sqlite")

View File

@ -30,6 +30,7 @@ from whoosh.qparser import MultifieldParser
class IndexService(object):
def __init__(self, config=None, whoosh_base=None):
if not whoosh_base and config:
whoosh_base = config.get("WHOOSH_BASE")
@ -83,7 +84,8 @@ class IndexService(object):
primary = field.name
continue
if field.name in model_class.__searchable__:
schema[field.name] = whoosh.fields.TEXT(analyzer=StemmingAnalyzer())
schema[field.name] = whoosh.fields.TEXT(
analyzer=StemmingAnalyzer())
return Schema(**schema), primary
def before_commit(self, session):
@ -91,18 +93,21 @@ class IndexService(object):
for model in session.new:
model_class = model.__class__
if hasattr(model_class, "__searchable__"):
self.to_update.setdefault(model_class.__name__, []).append(("new", model))
if hasattr(model_class, '__searchable__'):
self.to_update.setdefault(model_class.__name__, []).append(
("new", model))
for model in session.deleted:
model_class = model.__class__
if hasattr(model_class, "__searchable__"):
self.to_update.setdefault(model_class.__name__, []).append(("deleted", model))
if hasattr(model_class, '__searchable__'):
self.to_update.setdefault(model_class.__name__, []).append(
("deleted", model))
for model in session.dirty:
model_class = model.__class__
if hasattr(model_class, "__searchable__"):
self.to_update.setdefault(model_class.__name__, []).append(("changed", model))
if hasattr(model_class, '__searchable__'):
self.to_update.setdefault(model_class.__name__, []).append(
("changed", model))
def after_commit(self, session):
"""
@ -123,11 +128,16 @@ class IndexService(object):
# added as a new doc. Could probably replace this with a whoosh
# update.
writer.delete_by_term(primary_field, text_type(getattr(model, primary_field)))
writer.delete_by_term(
primary_field, text_type(getattr(model, primary_field)))
if change_type in ("new", "changed"):
attrs = dict((key, getattr(model, key)) for key in searchable)
attrs = {attr: text_type(getattr(model, attr)) for attr in attrs.keys()}
attrs = dict((key, getattr(model, key))
for key in searchable)
attrs = {
attr: text_type(getattr(model, attr))
for attr in attrs.keys()
}
attrs[primary_field] = text_type(getattr(model, primary_field))
writer.add_document(**attrs)
@ -148,7 +158,8 @@ class Searcher(object):
self.parser = MultifieldParser(list(fields), index.schema)
def __call__(self, session, query, limit=None):
results = self.index.searcher().search(self.parser.parse(query), limit=limit)
results = self.index.searcher().search(
self.parser.parse(query), limit=limit)
keys = [x[self.primary] for x in results]
primary_column = getattr(self.model_class, self.primary)

View File

@ -8,7 +8,7 @@ from builtins import str
import logging
import smtplib
from money import Money
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formatdate, make_msgid
@ -16,7 +16,7 @@ from email.utils import formatdate, make_msgid
LOGGER = logging.getLogger(__name__)
def send_email(server, port, subject, _from, _to, txt, html, username=None, password=None):
def send_email(server, port, subject, _from, _to, txt, html):
"""
Send an email
@ -33,18 +33,16 @@ def send_email(server, port, subject, _from, _to, txt, html, username=None, pass
return
server = smtplib.SMTP(server, port)
if username or password:
server.login(username or "", password or "")
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = _from
msg["To"] = ", ".join(_to)
msg["Date"] = formatdate()
msg["Message-ID"] = make_msgid()
msg = MIMEMultipart('alternative')
msg['Subject'] = subject
msg['From'] = _from
msg['To'] = ', '.join(_to)
msg['Date'] = formatdate()
msg['Message-ID'] = make_msgid()
msg.attach(MIMEText(txt, "plain", "utf-8"))
msg.attach(MIMEText(html, "html", "utf-8"))
msg.attach(MIMEText(txt, 'plain', 'utf-8'))
msg.attach(MIMEText(html, 'html', 'utf-8'))
server.sendmail(_from, _to, msg.as_string())
server.quit()
@ -61,33 +59,13 @@ def send_notification(config, flats):
if not flats:
return
i18n = {
"en": {
"subject": f"{len(flats)} new flats found!",
"hello": "Hello dear user",
"following_new_flats": "The following new flats have been found:",
"area": "area",
"cost": "cost",
"signature": "Hope you'll find what you were looking for.",
},
"fr": {
"subject": f"{len(flats)} nouvelles annonces disponibles !",
"hello": "Bonjour cher utilisateur",
"following_new_flats": "Voici les nouvelles annonces :",
"area": "surface",
"cost": "coût",
"signature": "Bonne recherche",
},
}
trs = i18n.get(config["notification_lang"], "en")
txt = trs["hello"] + ",\n\n\n\n"
html = f"""
txt = u'Hello dear user,\n\nThe following new flats have been found:\n\n'
html = """
<html>
<head></head>
<body>
<p>{trs["hello"]}!</p>
<p>{trs["following_new_flats"]}
<p>Hello dear user!</p>
<p>The following new flats have been found:
<ul>
"""
@ -97,58 +75,39 @@ def send_notification(config, flats):
for flat in flats:
title = str(flat.title)
flat_id = str(flat.id)
try:
area = str(int(flat.area))
except (TypeError, ValueError):
area = None
try:
cost = int(flat.cost)
except (TypeError, ValueError):
cost = None
area = str(flat.area)
cost = str(flat.cost)
currency = str(flat.currency)
txt += f"- {title}: {website_url}#/flat/{flat_id} "
html += f"""
txt += (
'- {}: {}#/flat/{} (area: {}, cost: {} {})\n'.format(
title, website_url, flat_id, area, cost, currency
)
)
html += """
<li>
<a href="{website_url}#/flat/{flat_id}">{title}</a>
"""
fields = []
if area:
fields.append(f"{trs['area']}: {area}")
if cost:
if currency == '$':
currency = 'USD'
if currency == '':
currency = 'EUR'
money = Money(cost, currency).format(config["notification_lang"])
fields.append(f"{trs['cost']}: {money.format()}")
if len(fields):
txt += f'({", ".join(fields)})'
html += f'({", ".join(fields)})'
html += "</li>"
txt += "\n"
<a href="{}#/flat/{}">{}</a>
(area: {}, cost: {} {})
</li>
""".format(website_url, flat_id, title, area, cost, currency)
html += "</ul>"
signature = f"\n{trs['signature']}\n\nBye!\nFlatisfy"
signature = (
u"\nHope you'll find what you were looking for.\n\nBye!\nFlatisfy"
)
txt += signature
html += signature.replace("\n", "<br>")
html += signature.replace('\n', '<br>')
html += """</p>
</body>
</html>"""
send_email(
config["smtp_server"],
send_email(config["smtp_server"],
config["smtp_port"],
trs["subject"],
"New flats found!",
config["smtp_from"],
config["smtp_to"],
txt,
html,
config.get("smtp_username"),
config.get("smtp_password"),
)
html)

View File

@ -10,5 +10,4 @@ class DataBuildError(Exception):
"""
Error occurring on building a data file.
"""
pass

View File

@ -9,7 +9,6 @@ import collections
import itertools
import json
import logging
from ratelimit import limits
from flatisfy import database
from flatisfy import tools
@ -20,27 +19,27 @@ LOGGER = logging.getLogger(__name__)
try:
from woob.capabilities.housing import Query, POSTS_TYPES, HOUSE_TYPES
from woob.core.bcall import CallErrors
from woob.core.ouiboube import WebNip
from woob.tools.json import WoobEncoder
from weboob.capabilities.housing import Query, POSTS_TYPES, HOUSE_TYPES
from weboob.core.bcall import CallErrors
from weboob.core.ouiboube import WebNip
from weboob.tools.json import WeboobEncoder
except ImportError:
LOGGER.error("Woob is not available on your system. Make sure you installed it.")
LOGGER.error("Weboob is not available on your system. Make sure you "
"installed it.")
raise
class WoobProxy(object):
class WeboobProxy(object):
"""
Wrapper around Woob ``WebNip`` class, to fetch housing posts without
Wrapper around Weboob ``WebNip`` class, to fetch housing posts without
having to spawn a subprocess.
"""
@staticmethod
def version():
"""
Get Woob version.
Get Weboob version.
:return: The installed Woob version.
:return: The installed Weboob version.
"""
return WebNip.VERSION
@ -64,7 +63,7 @@ class WoobProxy(object):
def __init__(self, config):
"""
Create a Woob handle and try to load the modules.
Create a Weboob handle and try to load the modules.
:param config: A config dict.
"""
@ -78,14 +77,14 @@ class WoobProxy(object):
self.webnip = WebNip(modules_path=config["modules_path"])
# Create backends
self.backends = []
for module in backends:
try:
self.backends.append(
self.webnip.load_backend(module, module, params={})
self.backends = [
self.webnip.load_backend(
module,
module,
params={}
)
except Exception as exc:
raise Exception('Unable to load module ' + module) from exc
for module in backends
]
def __enter__(self):
return self
@ -95,13 +94,13 @@ class WoobProxy(object):
def build_queries(self, constraints_dict):
"""
Build Woob ``woob.capabilities.housing.Query`` objects from the
Build Weboob ``weboob.capabilities.housing.Query`` objects from the
constraints defined in the configuration. Each query has at most 3
cities, to comply with housing websites limitations.
:param constraints_dict: A dictionary of constraints, as defined in the
config.
:return: A list of Woob ``woob.capabilities.housing.Query``
:return: A list of Weboob ``weboob.capabilities.housing.Query``
objects. Returns ``None`` if an error occurred.
"""
queries = []
@ -115,21 +114,28 @@ class WoobProxy(object):
except CallErrors as exc:
# If an error occured, just log it
LOGGER.error(
("An error occured while building query for postal code %s: %s"),
(
"An error occured while building query for "
"postal code %s: %s"
),
postal_code,
str(exc),
str(exc)
)
if not matching_cities:
# If postal code gave no match, warn the user
LOGGER.warn("Postal code %s could not be matched with a city.", postal_code)
LOGGER.warn(
"Postal code %s could not be matched with a city.",
postal_code
)
# Remove "TOUTES COMMUNES" entry which are duplicates of the individual
# cities entries in Logicimmo module.
matching_cities = [
city
for city in matching_cities
if not (city.backend == "logicimmo" and city.name.startswith("TOUTES COMMUNES"))
if not (city.backend == 'logicimmo' and
city.name.startswith('TOUTES COMMUNES'))
]
# Then, build queries by grouping cities by at most 3
@ -139,14 +145,21 @@ class WoobProxy(object):
try:
query.house_types = [
getattr(HOUSE_TYPES, house_type.upper()) for house_type in constraints_dict["house_types"]
getattr(
HOUSE_TYPES,
house_type.upper()
)
for house_type in constraints_dict["house_types"]
]
except AttributeError:
LOGGER.error("Invalid house types constraint.")
return None
try:
query.type = getattr(POSTS_TYPES, constraints_dict["type"].upper())
query.type = getattr(
POSTS_TYPES,
constraints_dict["type"].upper()
)
except AttributeError:
LOGGER.error("Invalid post type constraint.")
return None
@ -163,43 +176,37 @@ class WoobProxy(object):
def query(self, query, max_entries=None, store_personal_data=False):
"""
Fetch the housings posts matching a given Woob query.
Fetch the housings posts matching a given Weboob query.
:param query: A Woob `woob.capabilities.housing.Query`` object.
:param query: A Weboob `weboob.capabilities.housing.Query`` object.
:param max_entries: Maximum number of entries to fetch.
:param store_personal_data: Whether personal data should be fetched
from housing posts (phone number etc).
:return: The matching housing posts, dumped as a list of JSON objects.
"""
housings = []
# List the useful backends for this specific query
useful_backends = [x.backend for x in query.cities]
# TODO: Handle max_entries better
try:
for housing in itertools.islice(
self.webnip.do(
"search_housings",
query,
# Only run the call on the required backends.
# Otherwise, Woob is doing weird stuff and returning
# nonsense.
backends=[x for x in self.backends if x.name in useful_backends],
),
max_entries,
self.webnip.do('search_housings', query),
max_entries
):
if not store_personal_data:
housing.phone = None
housings.append(json.dumps(housing, cls=WoobEncoder))
housings.append(json.dumps(housing, cls=WeboobEncoder))
except CallErrors as exc:
# If an error occured, just log it
LOGGER.error("An error occured while fetching the housing posts: %s", str(exc))
LOGGER.error(
"An error occured while fetching the housing posts: %s",
str(exc)
)
return housings
def info(self, full_flat_id, store_personal_data=False):
"""
Get information (details) about an housing post.
:param full_flat_id: A Woob housing post id, in complete form
:param full_flat_id: A Weboob housing post id, in complete form
(ID@BACKEND)
:param store_personal_data: Whether personal data should be fetched
from housing posts (phone number etc).
@ -207,32 +214,40 @@ class WoobProxy(object):
"""
flat_id, backend_name = full_flat_id.rsplit("@", 1)
try:
backend = next(backend for backend in self.backends if backend.name == backend_name)
backend = next(
backend
for backend in self.backends
if backend.name == backend_name
)
except StopIteration:
LOGGER.error("Backend %s is not available.", backend_name)
return "{}"
try:
housing = backend.get_housing(flat_id)
# Otherwise, we miss the @backend afterwards
housing.id = full_flat_id
if not store_personal_data:
# Ensure phone is cleared
housing.phone = None
else:
# Ensure phone is fetched
backend.fillobj(housing, "phone")
# Otherwise, we miss the @backend afterwards
housing.id = full_flat_id
backend.fillobj(housing, 'phone')
return json.dumps(housing, cls=WoobEncoder)
return json.dumps(housing, cls=WeboobEncoder)
except Exception as exc: # pylint: disable=broad-except
# If an error occured, just log it
LOGGER.error("An error occured while fetching housing %s: %s", full_flat_id, str(exc))
LOGGER.error(
"An error occured while fetching housing %s: %s",
full_flat_id,
str(exc)
)
return "{}"
def fetch_flats(config):
"""
Fetch the available flats using the Woob config.
Fetch the available flats using the Flatboob / Weboob config.
:param config: A config dict.
:return: A dict mapping constraint in config to all available matching
@ -242,42 +257,38 @@ def fetch_flats(config):
for constraint_name, constraint in config["constraints"].items():
LOGGER.info("Loading flats for constraint %s...", constraint_name)
with WoobProxy(config) as woob_proxy:
queries = woob_proxy.build_queries(constraint)
with WeboobProxy(config) as weboob_proxy:
queries = weboob_proxy.build_queries(constraint)
housing_posts = []
for query in queries:
housing_posts.extend(woob_proxy.query(query, config["max_entries"], config["store_personal_data"]))
housing_posts = housing_posts[: config["max_entries"]]
housing_posts.extend(
weboob_proxy.query(query, config["max_entries"],
config["store_personal_data"])
)
LOGGER.info("Fetched %d flats.", len(housing_posts))
constraint_flats_list = [json.loads(flat) for flat in housing_posts]
constraint_flats_list = [WoobProxy.restore_decimal_fields(flat) for flat in constraint_flats_list]
constraint_flats_list = [WeboobProxy.restore_decimal_fields(flat)
for flat in constraint_flats_list]
fetched_flats[constraint_name] = constraint_flats_list
return fetched_flats
@limits(calls=10, period=60)
def fetch_details_rate_limited(config, flat_id):
"""
Limit flats fetching to at most 10 calls per minute to avoid rate banning
"""
return fetch_details(config, flat_id)
def fetch_details(config, flat_id):
"""
Fetch the additional details for a flat using Woob.
Fetch the additional details for a flat using Flatboob / Weboob.
:param config: A config dict.
:param flat_id: ID of the flat to fetch details for.
:return: A flat dict with all the available data.
"""
with WoobProxy(config) as woob_proxy:
with WeboobProxy(config) as weboob_proxy:
LOGGER.info("Loading additional details for flat %s.", flat_id)
woob_output = woob_proxy.info(flat_id, config["store_personal_data"])
weboob_output = weboob_proxy.info(flat_id,
config["store_personal_data"])
flat_details = json.loads(woob_output)
flat_details = WoobProxy.restore_decimal_fields(flat_details)
flat_details = json.loads(weboob_output)
flat_details = WeboobProxy.restore_decimal_fields(flat_details)
LOGGER.info("Fetched details for flat %s.", flat_id)
return flat_details
@ -305,7 +316,10 @@ def load_flats_from_file(json_file, config):
LOGGER.info("Found %d flats.", len(flats_list))
except (IOError, ValueError):
LOGGER.error("File %s is not a valid dump file.", json_file)
return {constraint_name: flats_list for constraint_name in config["constraints"]}
return {
constraint_name: flats_list
for constraint_name in config["constraints"]
}
def load_flats_from_db(config):

View File

@ -36,51 +36,48 @@ def refine_with_housing_criteria(flats_list, constraint):
for i, flat in enumerate(flats_list):
# Check postal code
postal_code = flat["flatisfy"].get("postal_code", None)
if postal_code and postal_code not in constraint["postal_codes"]:
LOGGER.info(
"Postal code %s for flat %s is out of range (%s).",
postal_code,
flat["id"],
", ".join(constraint["postal_codes"]),
)
is_ok[i] = False
# Check insee code
insee_code = flat["flatisfy"].get("insee_code", None)
if insee_code and "insee_codes" in constraint and insee_code not in constraint["insee_codes"]:
LOGGER.info(
"insee code %s for flat %s is out of range (%s).",
insee_code,
flat["id"],
", ".join(constraint["insee_codes"]),
)
is_ok[i] = False
if (
postal_code and
postal_code not in constraint["postal_codes"]
):
LOGGER.info("Postal code for flat %s is out of range.", flat["id"])
is_ok[i] = is_ok[i] and False
# Check time_to
for place_name, time in flat["flatisfy"].get("time_to", {}).items():
time = time["time"]
is_within_interval = tools.is_within_interval(time, *(constraint["time_to"][place_name]["time"]))
if not is_within_interval:
LOGGER.info(
"Flat %s is too far from place %s: %ds.",
flat["id"],
place_name,
is_within_interval = tools.is_within_interval(
time,
*(constraint["time_to"][place_name]["time"])
)
if not is_within_interval:
LOGGER.info("Flat %s is too far from place %s: %ds.",
flat["id"], place_name, time)
is_ok[i] = is_ok[i] and is_within_interval
# Check other fields
for field in ["area", "cost", "rooms", "bedrooms"]:
interval = constraint[field]
is_within_interval = tools.is_within_interval(flat.get(field, None), *interval)
if not is_within_interval:
LOGGER.info(
"%s %s for flat %s is out of range.", field.capitalize(), str(flat.get(field, None)), flat["id"]
is_within_interval = tools.is_within_interval(
flat.get(field, None),
*interval
)
if not is_within_interval:
LOGGER.info("%s for flat %s is out of range.",
field.capitalize(), flat["id"])
is_ok[i] = is_ok[i] and is_within_interval
return (
[flat for i, flat in enumerate(flats_list) if is_ok[i]],
[flat for i, flat in enumerate(flats_list) if not is_ok[i]],
[
flat
for i, flat in enumerate(flats_list)
if is_ok[i]
],
[
flat
for i, flat in enumerate(flats_list)
if not is_ok[i]
]
)
@ -106,43 +103,45 @@ def refine_with_details_criteria(flats_list, constraint):
for i, flat in enumerate(flats_list):
# Check number of pictures
has_enough_photos = tools.is_within_interval(len(flat.get("photos", [])), constraint["minimum_nb_photos"], None)
has_enough_photos = tools.is_within_interval(
len(flat.get('photos', [])),
constraint['minimum_nb_photos'],
None
)
if not has_enough_photos:
LOGGER.info(
"Flat %s only has %d photos, it should have at least %d.",
flat["id"],
len(flat["photos"]),
constraint["minimum_nb_photos"],
len(flat['photos']),
constraint['minimum_nb_photos']
)
is_ok[i] = False
for term in constraint["description_should_contain"]:
if isinstance(term, str) and term.lower() not in flat["text"].lower():
LOGGER.info(
("Description for flat %s does not contain required term '%s'."),
flat["id"],
term,
has_terms_in_description = True
if constraint["description_should_contain"]:
has_terms_in_description = all(
term in flat['text']
for term in constraint["description_should_contain"]
)
is_ok[i] = False
elif isinstance(term, list) and all(x.lower() not in flat["text"].lower() for x in term):
if not has_terms_in_description:
LOGGER.info(
("Description for flat %s does not contain any of required terms '%s'."),
flat["id"],
term,
)
is_ok[i] = False
for term in constraint["description_should_not_contain"]:
if term.lower() in flat["text"].lower():
LOGGER.info(
("Description for flat %s contains blacklisted term '%s'."),
flat["id"],
term,
("Description for flat %s does not contain all the required "
"terms."),
flat["id"]
)
is_ok[i] = False
return (
[flat for i, flat in enumerate(flats_list) if is_ok[i]],
[flat for i, flat in enumerate(flats_list) if not is_ok[i]],
[
flat
for i, flat in enumerate(flats_list)
if is_ok[i]
],
[
flat
for i, flat in enumerate(flats_list)
if not is_ok[i]
]
)
@ -164,25 +163,30 @@ def first_pass(flats_list, constraint, config):
# Handle duplicates based on ids
# Just remove them (no merge) as they should be the exact same object.
flats_list, _ = duplicates.detect(flats_list, key="id", merge=False, should_intersect=False)
flats_list, _ = duplicates.detect(
flats_list, key="id", merge=False, should_intersect=False
)
# Also merge duplicates based on urls (these may come from different
# flatboob backends)
# This is especially useful as some websites such as entreparticuliers
# contains a lot of leboncoin housings posts.
flats_list, duplicates_by_urls = duplicates.detect(flats_list, key="urls", merge=True, should_intersect=True)
flats_list, duplicates_by_urls = duplicates.detect(
flats_list, key="urls", merge=True, should_intersect=True
)
# Guess the postal codes
flats_list = metadata.guess_postal_code(flats_list, constraint, config)
if not config["ignore_station"]:
# Try to match with stations
flats_list = metadata.guess_stations(flats_list, constraint, config)
# Remove returned housing posts that do not match criteria
flats_list, ignored_list = refine_with_housing_criteria(flats_list, constraint)
return {"new": flats_list, "ignored": ignored_list, "duplicate": duplicates_by_urls}
flats_list, ignored_list = refine_with_housing_criteria(flats_list,
constraint)
return {
"new": flats_list,
"ignored": ignored_list,
"duplicate": duplicates_by_urls
}
@tools.timeit
def second_pass(flats_list, constraint, config):
@ -209,24 +213,28 @@ def second_pass(flats_list, constraint, config):
flats_list = metadata.guess_postal_code(flats_list, constraint, config)
# Better match with stations (confirm and check better)
if not config["ignore_station"]:
flats_list = metadata.guess_stations(flats_list, constraint, config)
# Compute travel time to specified points
flats_list = metadata.compute_travel_times(flats_list, constraint, config)
# Remove returned housing posts that do not match criteria
flats_list, ignored_list = refine_with_housing_criteria(flats_list, constraint)
flats_list, ignored_list = refine_with_housing_criteria(flats_list,
constraint)
# Remove returned housing posts which do not match criteria relying on
# fetched details.
flats_list, ignored_list = refine_with_details_criteria(flats_list, constraint)
flats_list, ignored_list = refine_with_details_criteria(flats_list,
constraint)
if config["serve_images_locally"]:
images.download_images(flats_list, config)
return {"new": flats_list, "ignored": ignored_list, "duplicate": []}
return {
"new": flats_list,
"ignored": ignored_list,
"duplicate": []
}
@tools.timeit
def third_pass(flats_list, config):
@ -245,4 +253,8 @@ def third_pass(flats_list, config):
# Deduplicate the list using every available data
flats_list, duplicate_flats = duplicates.deep_detect(flats_list, config)
return {"new": flats_list, "ignored": [], "duplicate": duplicate_flats}
return {
"new": flats_list,
"ignored": [],
"duplicate": duplicate_flats
}

View File

@ -5,23 +5,18 @@ Caching function for pictures.
from __future__ import absolute_import, print_function, unicode_literals
import collections
import hashlib
import os
import requests
import logging
from io import BytesIO
import PIL.Image
LOGGER = logging.getLogger(__name__)
class MemoryCache(object):
"""
A cache in memory.
"""
@staticmethod
def on_miss(key):
"""
@ -36,7 +31,7 @@ class MemoryCache(object):
def __init__(self):
self.hits = 0
self.misses = 0
self.map = collections.OrderedDict()
self.map = {}
def get(self, key):
"""
@ -89,7 +84,6 @@ class ImageCache(MemoryCache):
"""
A cache for images, stored in memory.
"""
@staticmethod
def compute_filename(url):
"""
@ -105,40 +99,24 @@ class ImageCache(MemoryCache):
"""
Helper to actually retrieve photos if not already cached.
"""
# If two many items in the cache, pop one
if len(self.map.keys()) > self.max_items:
self.map.popitem(last=False)
if url.endswith(".svg"):
# Skip SVG photo which are unsupported and unlikely to be relevant
return None
filepath = None
# Try to load from local folder
if self.storage_dir:
filepath = os.path.join(self.storage_dir, self.compute_filename(url))
filepath = os.path.join(
self.storage_dir,
self.compute_filename(url)
)
if os.path.isfile(filepath):
return PIL.Image.open(filepath)
# Otherwise, fetch it
try:
LOGGER.debug(f"Download photo from {url} to {filepath}")
image = PIL.Image.open(filepath)
else:
req = requests.get(url)
try:
req.raise_for_status()
image = PIL.Image.open(BytesIO(req.content))
if filepath:
if self.storage_dir:
image.save(filepath, format=image.format)
return image
except (requests.HTTPError, IOError) as exc:
LOGGER.info(f"Download photo from {url} failed: {exc}")
except (requests.HTTPError, IOError):
return None
return image
def __init__(self, max_items=200, storage_dir=None):
"""
:param max_items: Max number of items in the cache, to prevent Out Of
Memory errors.
:param storage_dir: Directory in which images should be stored.
"""
self.max_items = max_items
def __init__(self, storage_dir=None):
self.storage_dir = storage_dir
if self.storage_dir and not os.path.isdir(self.storage_dir):
os.makedirs(self.storage_dir)

View File

@ -35,14 +35,14 @@ def homogeneize_phone_number(numbers):
clean_numbers = []
for number in numbers.split(","):
for number in numbers.split(','):
number = number.strip()
number = number.replace(".", "")
number = number.replace(" ", "")
number = number.replace("-", "")
number = number.replace("(", "")
number = number.replace(")", "")
number = re.sub(r"^\+\d\d", "", number)
number = re.sub(r'^\+\d\d', "", number)
if not number.startswith("0"):
number = "0" + number
@ -94,7 +94,12 @@ def compare_photos(photo1, photo2, photo_cache, hash_threshold):
return False
def find_number_common_photos(flat1_photos, flat2_photos, photo_cache, hash_threshold):
def find_number_common_photos(
flat1_photos,
flat2_photos,
photo_cache,
hash_threshold
):
"""
Compute the number of common photos between the two lists of photos for the
flats.
@ -161,7 +166,7 @@ def detect(flats_list, key="id", merge=True, should_intersect=False):
for flat_key, matching_flats in seen.items():
if flat_key is None:
# If the key is None, it means Woob could not load the data. In
# If the key is None, it means Weboob could not load the data. In
# this case, we consider every matching item as being independant
# of the others, to avoid over-deduplication.
unique_flats_list.extend(matching_flats)
@ -169,21 +174,22 @@ def detect(flats_list, key="id", merge=True, should_intersect=False):
# Sort matching flats by backend precedence
matching_flats.sort(
key=lambda flat: next(
i for (i, backend) in enumerate(BACKENDS_BY_PRECEDENCE) if flat["id"].endswith(backend)
i for (i, backend) in enumerate(BACKENDS_BY_PRECEDENCE)
if flat["id"].endswith(backend)
),
reverse=True,
reverse=True
)
if len(matching_flats) > 1:
LOGGER.info(
'Found duplicates using key "%s": %s.',
LOGGER.info("Found duplicates using key \"%s\": %s.",
key,
[flat["id"] for flat in matching_flats],
)
[flat["id"] for flat in matching_flats])
# Otherwise, check the policy
if merge:
# If a merge is requested, do the merge
unique_flats_list.append(tools.merge_dicts(*matching_flats))
unique_flats_list.append(
tools.merge_dicts(*matching_flats)
)
else:
# Otherwise, just keep the most important of them
unique_flats_list.append(matching_flats[-1])
@ -197,7 +203,8 @@ def detect(flats_list, key="id", merge=True, should_intersect=False):
if should_intersect:
# We added some flats twice with the above method, let's deduplicate on
# id.
unique_flats_list, _ = detect(unique_flats_list, key="id", merge=True, should_intersect=False)
unique_flats_list, _ = detect(unique_flats_list, key="id", merge=True,
should_intersect=False)
return unique_flats_list, duplicate_flats
@ -243,12 +250,14 @@ def get_duplicate_score(flat1, flat2, photo_cache, hash_threshold):
# They should have the same postal code, if available
if (
"flatisfy" in flat1
and "flatisfy" in flat2
and flat1["flatisfy"].get("postal_code", None)
and flat2["flatisfy"].get("postal_code", None)
"flatisfy" in flat1 and "flatisfy" in flat2 and
flat1["flatisfy"].get("postal_code", None) and
flat2["flatisfy"].get("postal_code", None)
):
assert flat1["flatisfy"]["postal_code"] == flat2["flatisfy"]["postal_code"]
assert (
flat1["flatisfy"]["postal_code"] ==
flat2["flatisfy"]["postal_code"]
)
n_common_items += 1
# TODO: Better text comparison (one included in the other, fuzzymatch)
@ -263,23 +272,35 @@ def get_duplicate_score(flat1, flat2, photo_cache, hash_threshold):
flat2_phone = homogeneize_phone_number(flat2["phone"])
if flat1_phone and flat2_phone:
# Use an "in" test as there could be multiple phone numbers
# returned by a Woob module
# returned by a weboob module
if flat1_phone in flat2_phone or flat2_phone in flat1_phone:
n_common_items += 4 # Counts much more than the rest
# If the two flats are from the same website and have a
# different float part, consider they cannot be duplicates. See
# https://framagit.org/phyks/Flatisfy/issues/100.
both_are_from_same_backend = flat1["id"].split("@")[-1] == flat2["id"].split("@")[-1]
both_have_float_part = (flat1["area"] % 1) > 0 and (flat2["area"] % 1) > 0
both_have_equal_float_part = (flat1["area"] % 1) == (flat2["area"] % 1)
both_are_from_same_backend = (
flat1["id"].split("@")[-1] == flat2["id"].split("@")[-1]
)
both_have_float_part = (
(flat1["area"] % 1) > 0 and (flat2["area"] % 1) > 0
)
both_have_equal_float_part = (
(flat1["area"] % 1) == (flat2["area"] % 1)
)
if both_have_float_part and both_are_from_same_backend:
assert both_have_equal_float_part
if flat1.get("photos", []) and flat2.get("photos", []):
n_common_photos = find_number_common_photos(flat1["photos"], flat2["photos"], photo_cache, hash_threshold)
n_common_photos = find_number_common_photos(
flat1["photos"],
flat2["photos"],
photo_cache,
hash_threshold
)
min_number_photos = min(len(flat1["photos"]), len(flat2["photos"]))
min_number_photos = min(len(flat1["photos"]),
len(flat2["photos"]))
# Either all the photos are the same, or there are at least
# three common photos.
@ -311,7 +332,9 @@ def deep_detect(flats_list, config):
storage_dir = os.path.join(config["data_directory"], "images")
else:
storage_dir = None
photo_cache = ImageCache(storage_dir=storage_dir)
photo_cache = ImageCache(
storage_dir=storage_dir
)
LOGGER.info("Running deep duplicates detection.")
matching_flats = collections.defaultdict(list)
@ -324,26 +347,30 @@ def deep_detect(flats_list, config):
if flat2["id"] in matching_flats[flat1["id"]]:
continue
n_common_items = get_duplicate_score(flat1, flat2, photo_cache, config["duplicate_image_hash_threshold"])
n_common_items = get_duplicate_score(
flat1,
flat2,
photo_cache,
config["duplicate_image_hash_threshold"]
)
# Minimal score to consider they are duplicates
if n_common_items >= config["duplicate_threshold"]:
# Mark flats as duplicates
LOGGER.info(
("Found duplicates using deep detection: (%s, %s). Score is %d."),
("Found duplicates using deep detection: (%s, %s). "
"Score is %d."),
flat1["id"],
flat2["id"],
n_common_items,
n_common_items
)
matching_flats[flat1["id"]].append(flat2["id"])
matching_flats[flat2["id"]].append(flat1["id"])
if photo_cache.total():
LOGGER.debug(
"Photo cache: hits: %d%% / misses: %d%%.",
LOGGER.debug("Photo cache: hits: %d%% / misses: %d%%.",
photo_cache.hit_rate(),
photo_cache.miss_rate(),
)
photo_cache.miss_rate())
seen_ids = []
duplicate_flats = []
@ -354,11 +381,16 @@ def deep_detect(flats_list, config):
seen_ids.extend(matching_flats[flat_id])
to_merge = sorted(
[flat for flat in flats_list if flat["id"] in matching_flats[flat_id]],
[
flat
for flat in flats_list
if flat["id"] in matching_flats[flat_id]
],
key=lambda flat: next(
i for (i, backend) in enumerate(BACKENDS_BY_PRECEDENCE) if flat["id"].endswith(backend)
i for (i, backend) in enumerate(BACKENDS_BY_PRECEDENCE)
if flat["id"].endswith(backend)
),
reverse=True,
reverse=True
)
unique_flats_list.append(tools.merge_dicts(*to_merge))
# The ID of the added merged flat will be the one of the last item

View File

@ -22,8 +22,14 @@ def download_images(flats_list, config):
:param flats_list: A list of flats dicts.
:param config: A config dict.
"""
photo_cache = ImageCache(storage_dir=os.path.join(config["data_directory"], "images"))
for flat in flats_list:
photo_cache = ImageCache(
storage_dir=os.path.join(config["data_directory"], "images")
)
flats_list_length = len(flats_list)
for i, flat in enumerate(flats_list):
LOGGER.info(
"Downloading photos for flat %d/%d.", i + 1, flats_list_length
)
for photo in flat["photos"]:
# Download photo
image = photo_cache.get(photo["url"])

View File

@ -12,7 +12,6 @@ import re
from flatisfy import data
from flatisfy import tools
from flatisfy.constants import TimeToModes
from flatisfy.models.postal_code import PostalCode
from flatisfy.models.public_transport import PublicTransport
@ -76,10 +75,10 @@ def fuzzy_match(query, choices, limit=3, threshold=75):
Example::
>>> fuzzy_match("Paris 14ème", ["Ris", "ris", "Paris 14"], limit=1)
>>> match("Paris 14ème", ["Ris", "ris", "Paris 14"], limit=1)
[("Paris 14", 100)
>>> fuzzy_match( \
>>> match( \
"Saint-Jacques, Denfert-Rochereau (Colonel Rol-Tanguy), " \
"Mouton-Duvernet", \
["saint-jacques", "denfert rochereau", "duvernet", "toto"], \
@ -88,8 +87,8 @@ def fuzzy_match(query, choices, limit=3, threshold=75):
[('denfert rochereau', 100), ('saint-jacques', 76)]
"""
# TODO: Is there a better confidence measure?
normalized_query = tools.normalize_string(query).replace("saint", "st")
normalized_choices = [tools.normalize_string(choice).replace("saint", "st") for choice in choices]
normalized_query = tools.normalize_string(query)
normalized_choices = [tools.normalize_string(choice) for choice in choices]
# Remove duplicates in the choices list
unique_normalized_choices = tools.uniqify(normalized_choices)
@ -97,9 +96,13 @@ def fuzzy_match(query, choices, limit=3, threshold=75):
# Get the matches (normalized strings)
# Keep only ``limit`` matches.
matches = sorted(
[(choice, len(choice)) for choice in tools.uniqify(unique_normalized_choices) if choice in normalized_query],
[
(choice, len(choice))
for choice in tools.uniqify(unique_normalized_choices)
if choice in normalized_query
],
key=lambda x: x[1],
reverse=True,
reverse=True
)
if limit:
matches = matches[:limit]
@ -107,66 +110,22 @@ def fuzzy_match(query, choices, limit=3, threshold=75):
# Update confidence
if matches:
max_confidence = max(match[1] for match in matches)
matches = [(x[0], int(x[1] / max_confidence * 100)) for x in matches]
matches = [
(x[0], int(x[1] / max_confidence * 100))
for x in matches
]
# Convert back matches to original strings
# Also filter out matches below threshold
matches = [(choices[normalized_choices.index(x[0])], x[1]) for x in matches if x[1] >= threshold]
matches = [
(choices[normalized_choices.index(x[0])], x[1])
for x in matches
if x[1] >= threshold
]
return matches
def guess_location_position(location, cities, constraint, must_match):
# try to find a city
# Find all fuzzy-matching cities
postal_code = None
insee_code = None
position = None
matched_cities = fuzzy_match(location, [x.name for x in cities], limit=None)
if matched_cities:
# Find associated postal codes
matched_postal_codes = []
for matched_city_name, _ in matched_cities:
postal_code_objects_for_city = [x for x in cities if x.name == matched_city_name]
insee_code = [pc.insee_code for pc in postal_code_objects_for_city][0]
matched_postal_codes.extend(pc.postal_code for pc in postal_code_objects_for_city)
# Try to match them with postal codes in config constraint
matched_postal_codes_in_config = set(matched_postal_codes) & set(constraint["postal_codes"])
if matched_postal_codes_in_config:
# If there are some matched postal codes which are also in
# config, use them preferentially. This avoid ignoring
# incorrectly some flats in cities with multiple postal
# codes, see #110.
postal_code = next(iter(matched_postal_codes_in_config))
else:
# Otherwise, simply take any matched postal code.
postal_code = matched_postal_codes[0]
# take the city position
for matched_city_name, _ in matched_cities:
postal_code_objects_for_city = [
x for x in cities if x.name == matched_city_name and x.postal_code == postal_code
]
if len(postal_code_objects_for_city):
position = {
"lat": postal_code_objects_for_city[0].lat,
"lng": postal_code_objects_for_city[0].lng,
}
LOGGER.debug(("Found position %s using city %s."), position, matched_city_name)
break
if not postal_code and must_match:
postal_code = cities[0].postal_code
position = {
"lat": cities[0].lat,
"lng": cities[0].lng,
}
insee_code = cities[0].insee_code
return (postal_code, insee_code, position)
def guess_postal_code(flats_list, constraint, config, distance_threshold=20000):
"""
Try to guess the postal code from the location of the flats.
@ -181,27 +140,24 @@ def guess_postal_code(flats_list, constraint, config, distance_threshold=20000):
:return: An updated list of flats dict with guessed postal code.
"""
opendata = {"postal_codes": data.load_data(PostalCode, constraint, config)}
opendata = {
"postal_codes": data.load_data(PostalCode, constraint, config)
}
for flat in flats_list:
location = flat.get("location", None)
if not location:
addr = flat.get("address", None)
if addr:
location = addr["full_address"]
if not location:
# Skip everything if empty location
LOGGER.info(
("No location field for flat %s, skipping postal code lookup. (%s)"),
flat["id"],
flat.get("address"),
(
"No location field for flat %s, skipping postal "
"code lookup."
),
flat["id"]
)
continue
postal_code = None
insee_code = None
position = None
# Try to find a postal code directly
try:
postal_code = re.search(r"[0-9]{5}", location)
@ -209,22 +165,53 @@ def guess_postal_code(flats_list, constraint, config, distance_threshold=20000):
postal_code = postal_code.group(0)
# Check the postal code is within the db
assert postal_code in [x.postal_code for x in opendata["postal_codes"]]
assert postal_code in [x.postal_code
for x in opendata["postal_codes"]]
LOGGER.debug(
"Found postal code directly in location field for flat %s: %s.",
flat["id"],
postal_code,
LOGGER.info(
"Found postal code in location field for flat %s: %s.",
flat["id"], postal_code
)
except AssertionError:
postal_code = None
# Then fetch position (and postal_code is couldn't be found earlier)
cities = opendata["postal_codes"]
if postal_code:
cities = [x for x in cities if x.postal_code == postal_code]
(postal_code, insee_code, position) = guess_location_position(
location, cities, constraint, postal_code is not None
# If not found, try to find a city
if not postal_code:
# Find all fuzzy-matching cities
matched_cities = fuzzy_match(
location,
[x.name for x in opendata["postal_codes"]],
limit=None
)
if matched_cities:
# Find associated postal codes
matched_postal_codes = []
for matched_city_name, _ in matched_cities:
postal_code_objects_for_city = [
x for x in opendata["postal_codes"]
if x.name == matched_city_name
]
matched_postal_codes.extend(
pc.postal_code
for pc in postal_code_objects_for_city
)
# Try to match them with postal codes in config constraint
matched_postal_codes_in_config = (
set(matched_postal_codes) & set(constraint["postal_codes"])
)
if matched_postal_codes_in_config:
# If there are some matched postal codes which are also in
# config, use them preferentially. This avoid ignoring
# incorrectly some flats in cities with multiple postal
# codes, see #110.
postal_code = next(iter(matched_postal_codes_in_config))
else:
# Otherwise, simply take any matched postal code.
postal_code = matched_postal_codes[0]
LOGGER.info(
("Found postal code in location field through city lookup "
"for flat %s: %s."),
flat["id"], postal_code
)
# Check that postal code is not too far from the ones listed in config,
@ -232,28 +219,32 @@ def guess_postal_code(flats_list, constraint, config, distance_threshold=20000):
if postal_code and distance_threshold:
distance = min(
tools.distance(
next((x.lat, x.lng) for x in opendata["postal_codes"] if x.postal_code == postal_code),
next((x.lat, x.lng) for x in opendata["postal_codes"] if x.postal_code == constraint_postal_code),
next(
(x.lat, x.lng)
for x in opendata["postal_codes"]
if x.postal_code == postal_code
),
next(
(x.lat, x.lng)
for x in opendata["postal_codes"]
if x.postal_code == constraint_postal_code
)
)
for constraint_postal_code in constraint["postal_codes"]
)
if distance > distance_threshold:
LOGGER.info(
(
"Postal code %s found for flat %s @ %s is off-constraints "
("Postal code %s found for flat %s is off-constraints "
"(distance is %dm > %dm). Let's consider it is an "
"artifact match and keep the post without this postal "
"code."
),
"code."),
postal_code,
flat["id"],
location,
int(distance),
int(distance_threshold),
int(distance_threshold)
)
postal_code = None
position = None
# Store it
if postal_code:
@ -261,28 +252,12 @@ def guess_postal_code(flats_list, constraint, config, distance_threshold=20000):
if existing_postal_code and existing_postal_code != postal_code:
LOGGER.warning(
"Replacing previous postal code %s by %s for flat %s.",
existing_postal_code,
postal_code,
flat["id"],
existing_postal_code, postal_code, flat["id"]
)
flat["flatisfy"]["postal_code"] = postal_code
else:
LOGGER.info("No postal code found for flat %s.", flat["id"])
if insee_code:
flat["flatisfy"]["insee_code"] = insee_code
if position:
flat["flatisfy"]["position"] = position
LOGGER.debug(
"found postal_code=%s insee_code=%s position=%s for flat %s (%s).",
postal_code,
insee_code,
position,
flat["id"],
location,
)
return flats_list
@ -296,10 +271,10 @@ def guess_stations(flats_list, constraint, config):
:return: An updated list of flats dict with guessed nearby stations.
"""
distance_threshold = config["max_distance_housing_station"]
distance_threshold = config['max_distance_housing_station']
opendata = {
"postal_codes": data.load_data(PostalCode, constraint, config),
"stations": data.load_data(PublicTransport, constraint, config),
"stations": data.load_data(PublicTransport, constraint, config)
}
for flat in flats_list:
@ -307,28 +282,19 @@ def guess_stations(flats_list, constraint, config):
if not flat_station:
# Skip everything if empty station
LOGGER.info("No stations field for flat %s, skipping stations lookup.", flat["id"])
LOGGER.info(
"No stations field for flat %s, skipping stations lookup.",
flat["id"]
)
continue
# Woob modules can return several stations in a comma-separated list.
flat_stations = flat_station.split(",")
# But some stations containing a comma exist, so let's add the initial
# value to the list of stations to check if there was one.
if len(flat_stations) > 1:
flat_stations.append(flat_station)
matched_stations = []
for tentative_station in flat_stations:
matched_stations += fuzzy_match(
tentative_station,
matched_stations = fuzzy_match(
flat_station,
[x.name for x in opendata["stations"]],
limit=10,
threshold=50,
threshold=50
)
# Keep only one occurrence of each station
matched_stations = list(set(matched_stations))
# Filter out the stations that are obviously too far and not well
# guessed
good_matched_stations = []
@ -336,43 +302,54 @@ def guess_stations(flats_list, constraint, config):
if postal_code:
# If there is a postal code, check that the matched station is
# closed to it
postal_code_gps = next((x.lat, x.lng) for x in opendata["postal_codes"] if x.postal_code == postal_code)
postal_code_gps = next(
(x.lat, x.lng)
for x in opendata["postal_codes"]
if x.postal_code == postal_code
)
for station in matched_stations:
# Note that multiple stations with the same name exist in a
# city, hence the list of stations objects for a given matching
# station name.
stations_objects = [x for x in opendata["stations"] if x.name == station[0]]
stations_objects = [
x for x in opendata["stations"] if x.name == station[0]
]
for station_data in stations_objects:
distance = tools.distance((station_data.lat, station_data.lng), postal_code_gps)
distance = tools.distance(
(station_data.lat, station_data.lng),
postal_code_gps
)
if distance < distance_threshold:
# If at least one of the coordinates for a given
# station is close enough, that's ok and we can add
# the station
good_matched_stations.append(
{
good_matched_stations.append({
"key": station[0],
"name": station_data.name,
"confidence": station[1],
"gps": (station_data.lat, station_data.lng),
}
)
"gps": (station_data.lat, station_data.lng)
})
break
LOGGER.info(
("Station %s is too far from flat %s (%dm > %dm), discarding this station."),
("Station %s is too far from flat %s (%dm > %dm), "
"discarding this station."),
station[0],
flat["id"],
int(distance),
int(distance_threshold),
int(distance_threshold)
)
else:
LOGGER.info("No postal code for flat %s, skipping stations detection.", flat["id"])
LOGGER.info(
"No postal code for flat %s, skipping stations detection.",
flat["id"]
)
if not good_matched_stations:
# No stations found, log it and cotninue with next housing
LOGGER.info(
"No stations found for flat %s, matching %s.",
flat["id"],
flat["station"],
flat["station"]
)
continue
@ -380,20 +357,29 @@ def guess_stations(flats_list, constraint, config):
"Found stations for flat %s: %s (matching %s).",
flat["id"],
", ".join(x["name"] for x in good_matched_stations),
flat["station"],
flat["station"]
)
# If some stations were already filled in and the result is different,
# display some warning to the user
if "matched_stations" in flat["flatisfy"] and (
if (
"matched_stations" in flat["flatisfy"] and
(
# Do a set comparison, as ordering is not important
set([station["name"] for station in flat["flatisfy"]["matched_stations"]])
!= set([station["name"] for station in good_matched_stations])
set([
station["name"]
for station in flat["flatisfy"]["matched_stations"]
]) !=
set([
station["name"]
for station in good_matched_stations
])
)
):
LOGGER.warning(
"Replacing previously fetched stations for flat %s. Found "
"stations differ from the previously found ones.",
flat["id"],
flat["id"]
)
flat["flatisfy"]["matched_stations"] = good_matched_stations
@ -418,8 +404,9 @@ def compute_travel_times(flats_list, constraint, config):
if not flat["flatisfy"].get("matched_stations", []):
# Skip any flat without matched stations
LOGGER.info(
"Skipping travel time computation for flat %s. No matched stations.",
flat["id"],
"Skipping travel time computation for flat %s. No matched "
"stations.",
flat["id"]
)
continue
@ -430,15 +417,18 @@ def compute_travel_times(flats_list, constraint, config):
# For each place, loop over the stations close to the flat, and find
# the minimum travel time.
for place_name, place in constraint["time_to"].items():
mode = place.get("mode", "PUBLIC_TRANSPORT")
time_to_place_dict = None
for station in flat["flatisfy"]["matched_stations"]:
# Time from station is a dict with time and route
time_from_station_dict = tools.get_travel_time_between(
station["gps"], place["gps"], TimeToModes[mode], config
station["gps"],
place["gps"],
config
)
if time_from_station_dict and (
time_from_station_dict["time"] < time_to_place_dict or time_to_place_dict is None
if (
time_from_station_dict and
(time_from_station_dict["time"] < time_to_place_dict or
time_to_place_dict is None)
):
# If starting from this station makes the route to the
# specified place shorter, update
@ -446,11 +436,8 @@ def compute_travel_times(flats_list, constraint, config):
if time_to_place_dict:
LOGGER.info(
"Travel time between %s and flat %s by %s is %ds.",
place_name,
flat["id"],
mode,
time_to_place_dict["time"],
"Travel time between %s and flat %s is %ds.",
place_name, flat["id"], time_to_place_dict["time"]
)
flat["flatisfy"]["time_to"][place_name] = time_to_place_dict
return flats_list

View File

@ -0,0 +1,100 @@
# coding: utf-8
"""
This modules defines an SQLAlchemy ORM model for a search constraint.
"""
# pylint: disable=locally-disabled,invalid-name,too-few-public-methods
from __future__ import absolute_import, print_function, unicode_literals
import logging
from sqlalchemy import (
Column, Float, ForeignKey, Integer, String, Table
)
from sqlalchemy.orm import relationship
from sqlalchemy_utils.types.json import JSONType
from sqlalchemy_utils.types.scalar_list import ScalarListType
import enum
from sqlalchemy_enum_list import EnumListType
from flatisfy.database.base import BASE
LOGGER = logging.getLogger(__name__)
class HouseTypes(enum.Enum):
"""
An enum of the possible house types.
"""
APART = 0
HOUSE = 1
PARKING = 2
LAND = 3
OTHER = 4
UNKNOWN = 5
class PostTypes(enum.Enum):
"""
An enum of the possible posts types.
"""
RENT = 0
SALE = 1
SHARING = 2
association_table = Table(
'constraint_postal_codes_association', BASE.metadata,
Column('constraint_id', Integer, ForeignKey('constraints.id')),
Column('postal_code_id', Integer, ForeignKey('postal_codes.id'))
)
class Constraint(BASE):
"""
SQLAlchemy ORM model to store a search constraint.
"""
__tablename__ = "constraints"
id = Column(String, primary_key=True)
name = Column(String)
type = Column(EnumListType(PostTypes, int))
house_types = Column(EnumListType(HouseTypes, int))
# TODO: What happens when one delete a postal code?
postal_codes = relationship("PostalCode", secondary=association_table)
area_min = Column(Float, default=None) # in m^2
area_max = Column(Float, default=None) # in m^2
cost_min = Column(Float, default=None) # in currency unit
cost_max = Column(Float, default=None) # in currency unit
rooms_min = Column(Integer, default=None)
rooms_max = Column(Integer, default=None)
bedrooms_min = Column(Integer, default=None)
bedrooms_max = Column(Integer, default=None)
minimum_nb_photos = Column(Integer, default=None)
description_should_contain = Column(ScalarListType()) # list of terms
# Dict mapping names to {"gps": [lat, lng], "time": (min, max) }
# ``min`` and ``max`` are in seconds and can be ``null``.
# TODO: Use an additional time_to_places table?
time_to = Column(JSONType)
def __repr__(self):
return "<Constraint(id=%s, name=%s)>" % (self.id, self.name)
def json_api_repr(self):
"""
Return a dict representation of this constraint object that is JSON
serializable.
"""
constraint_repr = {
k: v
for k, v in self.__dict__.items()
if not k.startswith("_")
}
return constraint_repr

View File

@ -8,23 +8,16 @@ from __future__ import absolute_import, print_function, unicode_literals
import logging
import enum
import arrow
from sqlalchemy import (
Boolean,
Column,
DateTime,
Enum,
Float,
SmallInteger,
String,
Text,
inspect,
Column, Enum, Float, ForeignKey, Integer, SmallInteger, String, Table,
Text, inspect
)
from sqlalchemy.orm import validates
from sqlalchemy.orm import relationship, validates
from sqlalchemy_utils.types.arrow import ArrowType
from sqlalchemy_utils.types.json import JSONType
from sqlalchemy_utils.types.scalar_list import ScalarListType
from flatisfy.database.base import BASE
from flatisfy.database.types import MagicJSON
LOGGER = logging.getLogger(__name__)
@ -34,7 +27,6 @@ class FlatUtilities(enum.Enum):
"""
An enum of the possible utilities status for a flat entry.
"""
included = 10
unknown = 0
excluded = -10
@ -44,7 +36,6 @@ class FlatStatus(enum.Enum):
"""
An enum of the possible status for a flat entry.
"""
user_deleted = -100
duplicate = -20
ignored = -10
@ -57,54 +48,74 @@ class FlatStatus(enum.Enum):
# List of statuses that are automatically handled, and which the user cannot
# manually set through the UI.
AUTOMATED_STATUSES = [FlatStatus.new, FlatStatus.duplicate, FlatStatus.ignored]
AUTOMATED_STATUSES = [
FlatStatus.new,
FlatStatus.duplicate,
FlatStatus.ignored
]
stations_association_table = Table(
'stations_flats_association', BASE.metadata,
Column(
'public_transport_id', Integer, ForeignKey('public_transports.id')
),
Column('flat_id', Integer, ForeignKey('flats.id'))
)
class Flat(BASE):
"""
SQLAlchemy ORM model to store a flat.
"""
__tablename__ = "flats"
__searchable__ = ["title", "text", "station", "location", "details", "notes"]
__searchable__ = [
"title", "text", "station", "location", "details", "notes"
]
# Woob data
# Weboob data
id = Column(String, primary_key=True)
area = Column(Float)
bedrooms = Column(Float)
cost = Column(Float)
currency = Column(String)
utilities = Column(Enum(FlatUtilities), default=FlatUtilities.unknown)
date = Column(DateTime)
details = Column(MagicJSON)
date = Column(ArrowType)
details = Column(JSONType)
location = Column(String)
phone = Column(String)
photos = Column(MagicJSON)
photos = Column(JSONType)
rooms = Column(Float)
station = Column(String)
text = Column(Text)
title = Column(String)
urls = Column(MagicJSON)
merged_ids = Column(MagicJSON)
urls = Column(ScalarListType())
merged_ids = Column(ScalarListType())
notes = Column(Text)
notation = Column(SmallInteger, default=0)
is_expired = Column(Boolean, default=False)
# Flatisfy data
# TODO: Should be in another table with relationships
flatisfy_stations = Column(MagicJSON)
flatisfy_postal_code = Column(String)
flatisfy_time_to = Column(MagicJSON)
flatisfy_constraint = Column(String)
flatisfy_position = Column(MagicJSON)
# Flatisfy found stations
# TODO: What happens when one deletes a station?
flatisfy_stations = relationship("PublicTransport",
secondary=stations_association_table)
# Flatisfy found postal code
# TODO: What happens when one deletes a postal code?
flatisfy_postal_code_id = Column(Integer, ForeignKey('postal_codes.id'))
flatisfy_postal_code = relationship("PostalCode")
# Computed time to
flatisfy_time_to = Column(JSONType)
# Constraint relationship
# TODO: What happens when one deletes a constraint?
# TODO: A flat could match multiple constraints
flatisfy_constraint_id = Column(Integer, ForeignKey('constraints.id'))
flatisfy_constraint = relationship("Constraint")
# Status
status = Column(Enum(FlatStatus), default=FlatStatus.new)
# Date for visit
visit_date = Column(DateTime)
visit_date = Column(ArrowType)
@validates("utilities")
@validates('utilities')
def validate_utilities(self, _, utilities):
"""
Utilities validation method
@ -129,7 +140,8 @@ class Flat(BASE):
try:
return getattr(FlatStatus, status)
except (AttributeError, TypeError):
LOGGER.warn("Unkown flat status %s, ignoring it.", status)
LOGGER.warn("Unkown flat status %s, ignoring it.",
status)
return self.status.default.arg
@validates("notation")
@ -141,34 +153,14 @@ class Flat(BASE):
notation = int(notation)
assert notation >= 0 and notation <= 5
except (ValueError, AssertionError):
raise ValueError("notation should be an integer between 0 and 5")
raise ValueError('notation should be an integer between 0 and 5')
return notation
@validates("date")
def validate_date(self, _, date):
"""
Date validation method
"""
if date:
return arrow.get(date).naive
return None
@validates("visit_date")
def validate_visit_date(self, _, visit_date):
"""
Visit date validation method
"""
if visit_date:
return arrow.get(visit_date).naive
return None
@validates("photos")
def validate_photos(self, _, photos):
"""
Photos validation method
"""
if not photos:
photos = []
for photo in photos:
try:
# Remove computed hash to avoid storing it in db
@ -186,14 +178,22 @@ class Flat(BASE):
# Handle flatisfy metadata
flat_dict = flat_dict.copy()
if "flatisfy" in flat_dict:
flat_dict["flatisfy_stations"] = flat_dict["flatisfy"].get("matched_stations", [])
flat_dict["flatisfy_postal_code"] = flat_dict["flatisfy"].get("postal_code", None)
flat_dict["flatisfy_position"] = flat_dict["flatisfy"].get("position", None)
flat_dict["flatisfy_time_to"] = flat_dict["flatisfy"].get("time_to", {})
flat_dict["flatisfy_constraint"] = flat_dict["flatisfy"].get("constraint", "default")
flat_dict["flatisfy_stations"] = (
flat_dict["flatisfy"].get("matched_stations", [])
)
flat_dict["flatisfy_postal_code"] = (
flat_dict["flatisfy"].get("postal_code", None)
)
flat_dict["flatisfy_time_to"] = (
flat_dict["flatisfy"].get("time_to", {})
)
flat_dict["flatisfy_constraint"] = (
flat_dict["flatisfy"].get("constraint", "default")
)
del flat_dict["flatisfy"]
flat_dict = {k: v for k, v in flat_dict.items() if k in inspect(Flat).columns.keys()}
flat_dict = {k: v for k, v in flat_dict.items()
if k in inspect(Flat).columns.keys()}
return Flat(**flat_dict)
def __repr__(self):
@ -204,7 +204,11 @@ class Flat(BASE):
Return a dict representation of this flat object that is JSON
serializable.
"""
flat_repr = {k: v for k, v in self.__dict__.items() if not k.startswith("_")}
flat_repr = {
k: v
for k, v in self.__dict__.items()
if not k.startswith("_")
}
if isinstance(flat_repr["status"], FlatStatus):
flat_repr["status"] = flat_repr["status"].name
if isinstance(flat_repr["utilities"], FlatUtilities):

View File

@ -7,7 +7,9 @@ from __future__ import absolute_import, print_function, unicode_literals
import logging
from sqlalchemy import Column, Float, Integer, String, UniqueConstraint
from sqlalchemy import (
Column, Float, Integer, String, UniqueConstraint
)
from flatisfy.database.base import BASE
@ -19,7 +21,6 @@ class PostalCode(BASE):
"""
SQLAlchemy ORM model to store a postal code opendata.
"""
__tablename__ = "postal_codes"
id = Column(Integer, primary_key=True)
@ -27,7 +28,6 @@ class PostalCode(BASE):
# following ISO 3166-2.
area = Column(String, index=True)
postal_code = Column(String, index=True)
insee_code = Column(String, index=True)
name = Column(String, index=True)
lat = Column(Float)
lng = Column(Float)
@ -41,4 +41,8 @@ class PostalCode(BASE):
Return a dict representation of this postal code object that is JSON
serializable.
"""
return {k: v for k, v in self.__dict__.items() if not k.startswith("_")}
return {
k: v
for k, v in self.__dict__.items()
if not k.startswith("_")
}

View File

@ -7,7 +7,9 @@ from __future__ import absolute_import, print_function, unicode_literals
import logging
from sqlalchemy import Column, Float, Integer, String
from sqlalchemy import (
Column, Float, Integer, String
)
from flatisfy.database.base import BASE
@ -19,7 +21,6 @@ class PublicTransport(BASE):
"""
SQLAlchemy ORM model to store public transport opendata.
"""
__tablename__ = "public_transports"
id = Column(Integer, primary_key=True)

View File

@ -15,7 +15,42 @@
"photos": [
{
"id": "0an3yarge9y446j653dewxu0jwy33pmwar47k2qym.jpg",
"url": "flatisfy/test_files/127028739@seloger.jpg",
"url": "https://v.seloger.com/s/width/800/visuels/0/a/n/3/0an3yarge9y446j653dewxu0jwy33pmwar47k2qym.jpg",
"data": null
},
{
"id": "1qnz6hpffcrd1c71htbooubgb7s57d82ie1v0zyf2.jpg",
"url": "https://v.seloger.com/s/width/800/visuels/1/q/n/z/1qnz6hpffcrd1c71htbooubgb7s57d82ie1v0zyf2.jpg",
"data": null
},
{
"id": "16bv8yqgytefa1fq57hyk6e0y6ox8t2mh8wj2dgxq.jpg",
"url": "https://v.seloger.com/s/width/800/visuels/1/6/b/v/16bv8yqgytefa1fq57hyk6e0y6ox8t2mh8wj2dgxq.jpg",
"data": null
},
{
"id": "1o23blwk87ew95e3vcq5ygyk10z2hy82fzo5j6hha.jpg",
"url": "https://v.seloger.com/s/width/800/visuels/1/o/2/3/1o23blwk87ew95e3vcq5ygyk10z2hy82fzo5j6hha.jpg",
"data": null
},
{
"id": "20vuxbdp160sot4ccryf6g7g4rwxrkhz3b3tmq7zy.jpg",
"url": "https://v.seloger.com/s/width/800/visuels/2/0/v/u/20vuxbdp160sot4ccryf6g7g4rwxrkhz3b3tmq7zy.jpg",
"data": null
},
{
"id": "00d9bpezie95lqtfmoccqg1ddrld2m64c2mcod5ha.jpg",
"url": "https://v.seloger.com/s/width/800/visuels/0/0/d/9/00d9bpezie95lqtfmoccqg1ddrld2m64c2mcod5ha.jpg",
"data": null
},
{
"id": "0lhqf881qm2j03hz5581d8ggplp1xwwchb2rtoqgu.jpg",
"url": "https://v.seloger.com/s/width/800/visuels/0/l/h/q/0lhqf881qm2j03hz5581d8ggplp1xwwchb2rtoqgu.jpg",
"data": null
},
{
"id": "0chwbagbf8tc0qf9sd3wryzl4gm7hkswcnrtnx2bi.jpg",
"url": "https://v.seloger.com/s/width/800/visuels/0/c/h/w/0chwbagbf8tc0qf9sd3wryzl4gm7hkswcnrtnx2bi.jpg",
"data": null
}
],
@ -34,8 +69,5 @@
"Etages": "30",
"Parking": "1",
"Salle de Séjour": ""
},
"flatisfy": {
"postal_code": "35000"
}
}

View File

@ -15,7 +15,42 @@
"photos": [
{
"id": "f9b2da6dfa184759aa0c349edb1cd037.jpg",
"url": "flatisfy/test_files/14428129@explorimmo.jpg",
"url": "http://thbr.figarocms.net/images/2qEDBqRV-QNlp4fHVNhSCWlt6rU=/560x420/filters:fill(f6f6f6):quality(80):strip_icc()/f9b2da6dfa184759aa0c349edb1cd037.jpg",
"data": null
},
{
"id": "3f2cc9dc429d4e3dbb9f4216f109d224.jpg",
"url": "http://thbr.figarocms.net/images/DulZQyZkkwa0ZFBT1nYD9rUD0A4=/560x420/filters:fill(f6f6f6):quality(80):strip_icc()/3f2cc9dc429d4e3dbb9f4216f109d224.jpg",
"data": null
},
{
"id": "56ae1db620f44af6b860df10eba55870.jpg",
"url": "http://thbr.figarocms.net/images/EpvEffLcFbBT7spEZB2dcOHaZwA=/560x420/filters:fill(f6f6f6):quality(80):strip_icc()/56ae1db620f44af6b860df10eba55870.jpg",
"data": null
},
{
"id": "5acdef1f05314fe19111a0c3d92b8fe5.jpg",
"url": "http://thbr.figarocms.net/images/wHtDlJMwIrMC3cWXi8ASN4I6Zl4=/560x420/filters:fill(f6f6f6):quality(80):strip_icc()/5acdef1f05314fe19111a0c3d92b8fe5.jpg",
"data": null
},
{
"id": "16c686ea91b248129fe60011d61e060b.jpg",
"url": "http://thbr.figarocms.net/images/SD5VT1gxRSXSlt3pAz8r_SI3rqw=/560x420/filters:fill(f6f6f6):quality(80):strip_icc()/16c686ea91b248129fe60011d61e060b.jpg",
"data": null
},
{
"id": "e6a67d42709d443481da0feb9a7e11a1.jpg",
"url": "http://thbr.figarocms.net/images/u8PGKXqC0CL9AyEOI5T9TFeGs-Y=/560x420/filters:fill(f6f6f6):quality(80):strip_icc()/e6a67d42709d443481da0feb9a7e11a1.jpg",
"data": null
},
{
"id": "6888cc7bc823402198205e480c8cab6c.jpg",
"url": "http://thbr.figarocms.net/images/-3AseFCRaleidG2vsDJpA5BLBa4=/560x420/filters:fill(f6f6f6):quality(80):strip_icc()/6888cc7bc823402198205e480c8cab6c.jpg",
"data": null
},
{
"id": "d40dbeea9e424ea2a846f5683746ea9e.jpg",
"url": "http://thbr.figarocms.net/images/TMKBtBuucYge-BgCoUGRjxZjdBE=/560x420/filters:fill(f6f6f6):quality(80):strip_icc()/d40dbeea9e424ea2a846f5683746ea9e.jpg",
"data": null
}
],
@ -38,8 +73,5 @@
"agencyFees": 0,
"availabilityDate": null,
"guarantee": 0
},
"flatisfy": {
"postal_code": "35000"
}
}

View File

@ -30,7 +30,6 @@ class LocalImageCache(ImageCache):
"""
A local cache for images, stored in memory.
"""
@staticmethod
def on_miss(path):
"""
@ -47,34 +46,48 @@ class TestTexts(unittest.TestCase):
"""
Checks string normalizations.
"""
def test_roman_numbers(self):
"""
Checks roman numbers replacement.
"""
self.assertEqual("XIV", tools.convert_arabic_to_roman("14"))
self.assertEqual(
"XIV",
tools.convert_arabic_to_roman("14")
)
self.assertEqual("XXXIX", tools.convert_arabic_to_roman("39"))
self.assertEqual(
"XXXIX",
tools.convert_arabic_to_roman("39")
)
self.assertEqual("40", tools.convert_arabic_to_roman("40"))
self.assertEqual(
"40",
tools.convert_arabic_to_roman("40")
)
self.assertEqual("1987", tools.convert_arabic_to_roman("1987"))
self.assertEqual(
"1987",
tools.convert_arabic_to_roman("1987")
)
self.assertEqual(
"Dans le XVe arrondissement",
tools.convert_arabic_to_roman_in_text("Dans le 15e arrondissement"),
tools.convert_arabic_to_roman_in_text("Dans le 15e arrondissement")
)
self.assertEqual("XXeme arr.", tools.convert_arabic_to_roman_in_text("20eme arr."))
self.assertEqual(
"XXeme arr.",
tools.convert_arabic_to_roman_in_text("20eme arr.")
)
self.assertEqual(
"A AIX EN PROVENCE",
tools.convert_arabic_to_roman_in_text("A AIX EN PROVENCE"),
tools.convert_arabic_to_roman_in_text("A AIX EN PROVENCE")
)
self.assertEqual(
"Montigny Le Bretonneux",
tools.convert_arabic_to_roman_in_text("Montigny Le Bretonneux"),
tools.convert_arabic_to_roman_in_text("Montigny Le Bretonneux")
)
def test_roman_numbers_in_text(self):
@ -84,54 +97,77 @@ class TestTexts(unittest.TestCase):
"""
self.assertEqual(
"dans le XVe arrondissement",
tools.normalize_string("Dans le 15e arrondissement"),
tools.normalize_string("Dans le 15e arrondissement")
)
self.assertEqual("paris XVe, 75005", tools.normalize_string("Paris 15e, 75005"))
self.assertEqual(
"paris XVe, 75005",
tools.normalize_string("Paris 15e, 75005")
)
self.assertEqual("paris xve, 75005", tools.normalize_string("Paris XVe, 75005"))
self.assertEqual(
"paris xve, 75005",
tools.normalize_string("Paris XVe, 75005")
)
def test_multiple_whitespaces(self):
"""
Checks whitespaces are collapsed.
"""
self.assertEqual("avec ascenseur", tools.normalize_string("avec ascenseur"))
self.assertEqual(
"avec ascenseur",
tools.normalize_string("avec ascenseur")
)
def test_whitespace_trim(self):
"""
Checks that trailing and beginning whitespaces are trimmed.
"""
self.assertEqual("rennes 35000", tools.normalize_string(" Rennes 35000 "))
self.assertEqual(
"rennes 35000",
tools.normalize_string(" Rennes 35000 ")
)
def test_accents(self):
"""
Checks accents are replaced.
"""
self.assertEqual("eeeaui", tools.normalize_string(u"éèêàüï"))
self.assertEqual(
"eeeaui",
tools.normalize_string(u"éèêàüï")
)
class TestPhoneNumbers(unittest.TestCase):
"""
Checks phone numbers normalizations.
"""
def test_prefix(self):
"""
Checks phone numbers with international prefixes.
"""
self.assertEqual("0605040302", duplicates.homogeneize_phone_number("+33605040302"))
self.assertEqual(
"0605040302",
duplicates.homogeneize_phone_number("+33605040302")
)
def test_dots_separators(self):
"""
Checks phone numbers with dots.
"""
self.assertEqual("0605040302", duplicates.homogeneize_phone_number("06.05.04.03.02"))
self.assertEqual(
"0605040302",
duplicates.homogeneize_phone_number("06.05.04.03.02")
)
def test_spaces_separators(self):
"""
Checks phone numbers with spaces.
"""
self.assertEqual("0605040302", duplicates.homogeneize_phone_number("06 05 04 03 02"))
self.assertEqual(
"0605040302",
duplicates.homogeneize_phone_number("06 05 04 03 02")
)
class TestPhotos(unittest.TestCase):
@ -147,104 +183,96 @@ class TestPhotos(unittest.TestCase):
"""
Compares a photo against itself.
"""
photo = {"url": TESTS_DATA_DIR + "127028739@seloger.jpg"}
photo = {
"url": TESTS_DATA_DIR + "127028739@seloger.jpg"
}
self.assertTrue(duplicates.compare_photos(photo, photo, self.IMAGE_CACHE, self.HASH_THRESHOLD))
self.assertTrue(duplicates.compare_photos(
photo,
photo,
self.IMAGE_CACHE,
self.HASH_THRESHOLD
))
def test_different_photos(self):
"""
Compares two different photos.
"""
self.assertFalse(
duplicates.compare_photos(
self.assertFalse(duplicates.compare_photos(
{"url": TESTS_DATA_DIR + "127028739@seloger.jpg"},
{"url": TESTS_DATA_DIR + "127028739-2@seloger.jpg"},
self.IMAGE_CACHE,
self.HASH_THRESHOLD,
)
)
self.HASH_THRESHOLD
))
self.assertFalse(
duplicates.compare_photos(
self.assertFalse(duplicates.compare_photos(
{"url": TESTS_DATA_DIR + "127028739-2@seloger.jpg"},
{"url": TESTS_DATA_DIR + "127028739-3@seloger.jpg"},
self.IMAGE_CACHE,
self.HASH_THRESHOLD,
)
)
self.HASH_THRESHOLD
))
def test_matching_photos(self):
"""
Compares two matching photos with different size and source.
"""
self.assertTrue(
duplicates.compare_photos(
self.assertTrue(duplicates.compare_photos(
{"url": TESTS_DATA_DIR + "127028739@seloger.jpg"},
{"url": TESTS_DATA_DIR + "14428129@explorimmo.jpg"},
self.IMAGE_CACHE,
self.HASH_THRESHOLD,
)
)
self.HASH_THRESHOLD
))
self.assertTrue(
duplicates.compare_photos(
self.assertTrue(duplicates.compare_photos(
{"url": TESTS_DATA_DIR + "127028739-2@seloger.jpg"},
{"url": TESTS_DATA_DIR + "14428129-2@explorimmo.jpg"},
self.IMAGE_CACHE,
self.HASH_THRESHOLD,
)
)
self.HASH_THRESHOLD
))
self.assertTrue(
duplicates.compare_photos(
self.assertTrue(duplicates.compare_photos(
{"url": TESTS_DATA_DIR + "127028739-3@seloger.jpg"},
{"url": TESTS_DATA_DIR + "14428129-3@explorimmo.jpg"},
self.IMAGE_CACHE,
self.HASH_THRESHOLD,
)
)
self.HASH_THRESHOLD
))
self.assertTrue(
duplicates.compare_photos(
self.assertTrue(duplicates.compare_photos(
{"url": TESTS_DATA_DIR + "127028739@seloger.jpg"},
{"url": TESTS_DATA_DIR + "127028739-watermark@seloger.jpg"},
self.IMAGE_CACHE,
self.HASH_THRESHOLD,
)
)
self.HASH_THRESHOLD
))
def test_matching_cropped_photos(self):
"""
Compares two matching photos with one being cropped.
"""
# Fixme: the image hash treshold should be 10 ideally
self.assertTrue(
duplicates.compare_photos(
self.assertTrue(duplicates.compare_photos(
{"url": TESTS_DATA_DIR + "vertical.jpg"},
{"url": TESTS_DATA_DIR + "vertical-cropped.jpg"},
self.IMAGE_CACHE,
20,
)
)
20
))
# Fixme: the image hash treshold should be 10 ideally
self.assertTrue(
duplicates.compare_photos(
self.assertTrue(duplicates.compare_photos(
{"url": TESTS_DATA_DIR + "13783671@explorimmo.jpg"},
{"url": TESTS_DATA_DIR + "124910113@seloger.jpg"},
self.IMAGE_CACHE,
20,
)
)
20
))
class TestImageCache(unittest.TestCase):
"""
Checks image cache is working as expected.
"""
def __init__(self, *args, **kwargs):
self.IMAGE_CACHE = ImageCache(storage_dir=tempfile.mkdtemp(prefix="flatisfy-")) # pylint: disable=invalid-name
self.IMAGE_CACHE = ImageCache( # pylint: disable=invalid-name
storage_dir=tempfile.mkdtemp(prefix="flatisfy-")
)
super(TestImageCache, self).__init__(*args, **kwargs)
def test_invalid_url(self):
@ -252,28 +280,33 @@ class TestImageCache(unittest.TestCase):
Check that it returns nothing on an invalid URL.
"""
# See https://framagit.org/phyks/Flatisfy/issues/116.
self.assertIsNone(self.IMAGE_CACHE.get("https://httpbin.org/status/404"))
self.assertIsNone(self.IMAGE_CACHE.get("https://httpbin.org/status/500"))
self.assertIsNone(
self.IMAGE_CACHE.get("https://httpbin.org/status/404")
)
self.assertIsNone(
self.IMAGE_CACHE.get("https://httpbin.org/status/500")
)
def test_invalid_data(self):
"""
Check that it returns nothing on an invalid data.
"""
# See https://framagit.org/phyks/Flatisfy/issues/116.
self.assertIsNone(self.IMAGE_CACHE.get("https://httpbin.org/"))
self.assertIsNone(
self.IMAGE_CACHE.get("https://httpbin.org/")
)
class TestDuplicates(unittest.TestCase):
"""
Checks duplicates detection.
"""
DUPLICATES_MIN_SCORE_WITHOUT_PHOTOS = 8 # pylint: disable=invalid-name
DUPLICATES_MIN_SCORE_WITH_PHOTOS = 15 # pylint: disable=invalid-name
HASH_THRESHOLD = 10 # pylint: disable=invalid-name
def __init__(self, *args, **kwargs):
self.IMAGE_CACHE = LocalImageCache( # pylint: disable=invalid-name
self.IMAGE_CACHE = ImageCache( # pylint: disable=invalid-name
storage_dir=tempfile.mkdtemp(prefix="flatisfy-")
)
super(TestDuplicates, self).__init__(*args, **kwargs)
@ -283,7 +316,9 @@ class TestDuplicates(unittest.TestCase):
"""
Generates a fake flat post.
"""
backend = BACKENDS_BY_PRECEDENCE[random.randint(0, len(BACKENDS_BY_PRECEDENCE) - 1)]
backend = BACKENDS_BY_PRECEDENCE[
random.randint(0, len(BACKENDS_BY_PRECEDENCE) - 1)
]
return {
"id": str(random.randint(100000, 199999)) + "@" + backend,
"phone": "0607080910",
@ -291,7 +326,7 @@ class TestDuplicates(unittest.TestCase):
"utilities": "",
"area": random.randint(200, 1500) / 10,
"cost": random.randint(100000, 300000),
"bedrooms": random.randint(1, 4),
"bedrooms": random.randint(1, 4)
}
@staticmethod
@ -315,7 +350,10 @@ class TestDuplicates(unittest.TestCase):
"""
flat1 = self.generate_fake_flat()
flat2 = copy.deepcopy(flat1)
score = duplicates.get_duplicate_score(flat1, flat2, self.IMAGE_CACHE, self.HASH_THRESHOLD)
score = duplicates.get_duplicate_score(
flat1, flat2,
self.IMAGE_CACHE, self.HASH_THRESHOLD
)
self.assertGreaterEqual(score, self.DUPLICATES_MIN_SCORE_WITHOUT_PHOTOS)
def test_different_prices(self):
@ -326,7 +364,10 @@ class TestDuplicates(unittest.TestCase):
flat2 = copy.deepcopy(flat1)
flat2["cost"] += 1000
score = duplicates.get_duplicate_score(flat1, flat2, self.IMAGE_CACHE, self.HASH_THRESHOLD)
score = duplicates.get_duplicate_score(
flat1, flat2,
self.IMAGE_CACHE, self.HASH_THRESHOLD
)
self.assertLess(score, self.DUPLICATES_MIN_SCORE_WITHOUT_PHOTOS)
def test_different_rooms(self):
@ -338,7 +379,10 @@ class TestDuplicates(unittest.TestCase):
flat2 = copy.deepcopy(flat1)
flat2["rooms"] += 1
score = duplicates.get_duplicate_score(flat1, flat2, self.IMAGE_CACHE, self.HASH_THRESHOLD)
score = duplicates.get_duplicate_score(
flat1, flat2,
self.IMAGE_CACHE, self.HASH_THRESHOLD
)
self.assertLess(score, self.DUPLICATES_MIN_SCORE_WITHOUT_PHOTOS)
def test_different_areas(self):
@ -349,7 +393,10 @@ class TestDuplicates(unittest.TestCase):
flat2 = copy.deepcopy(flat1)
flat2["area"] += 10
score = duplicates.get_duplicate_score(flat1, flat2, self.IMAGE_CACHE, self.HASH_THRESHOLD)
score = duplicates.get_duplicate_score(
flat1, flat2,
self.IMAGE_CACHE, self.HASH_THRESHOLD
)
self.assertLess(score, self.DUPLICATES_MIN_SCORE_WITHOUT_PHOTOS)
def test_different_areas_decimals(self):
@ -362,7 +409,10 @@ class TestDuplicates(unittest.TestCase):
flat1["area"] = 50.65
flat2["area"] = 50.37
score = duplicates.get_duplicate_score(flat1, flat2, self.IMAGE_CACHE, self.HASH_THRESHOLD)
score = duplicates.get_duplicate_score(
flat1, flat2,
self.IMAGE_CACHE, self.HASH_THRESHOLD
)
self.assertLess(score, self.DUPLICATES_MIN_SCORE_WITHOUT_PHOTOS)
def test_different_phones(self):
@ -374,7 +424,10 @@ class TestDuplicates(unittest.TestCase):
flat2 = copy.deepcopy(flat1)
flat2["phone"] = "0708091011"
score = duplicates.get_duplicate_score(flat1, flat2, self.IMAGE_CACHE, self.HASH_THRESHOLD)
score = duplicates.get_duplicate_score(
flat1, flat2,
self.IMAGE_CACHE, self.HASH_THRESHOLD
)
self.assertLess(score, self.DUPLICATES_MIN_SCORE_WITHOUT_PHOTOS)
def test_real_duplicates(self):
@ -382,59 +435,64 @@ class TestDuplicates(unittest.TestCase):
Two flats with same price, area and rooms quantity should be detected
as duplicates.
"""
flats = self.load_files("127028739@seloger", "14428129@explorimmo")
flats = self.load_files(
"127028739@seloger",
"14428129@explorimmo"
)
score = duplicates.get_duplicate_score(flats[0], flats[1], self.IMAGE_CACHE, self.HASH_THRESHOLD)
score = duplicates.get_duplicate_score(
flats[0], flats[1],
self.IMAGE_CACHE, self.HASH_THRESHOLD
)
self.assertGreaterEqual(score, self.DUPLICATES_MIN_SCORE_WITH_PHOTOS)
# TODO: fixme, find new testing examples
# flats = self.load_files(
# "128358415@seloger",
# "14818297@explorimmo"
# )
flats = self.load_files(
"128358415@seloger",
"14818297@explorimmo"
)
# score = duplicates.get_duplicate_score(
# flats[0], flats[1],
# self.IMAGE_CACHE, 20
# )
# self.assertGreaterEqual(score, self.DUPLICATES_MIN_SCORE_WITH_PHOTOS)
score = duplicates.get_duplicate_score(
flats[0], flats[1],
self.IMAGE_CACHE, 20
)
self.assertGreaterEqual(score, self.DUPLICATES_MIN_SCORE_WITH_PHOTOS)
# # Different number of photos, and some are cropped
# flats = self.load_files(
# "124910113@seloger",
# "13783671@explorimmo"
# )
# Different number of photos, and some are cropped
flats = self.load_files(
"124910113@seloger",
"13783671@explorimmo"
)
# score = duplicates.get_duplicate_score(
# flats[0], flats[1],
# self.IMAGE_CACHE, 20
# )
# self.assertGreaterEqual(score, self.DUPLICATES_MIN_SCORE_WITH_PHOTOS)
score = duplicates.get_duplicate_score(
flats[0], flats[1],
self.IMAGE_CACHE, 20
)
self.assertGreaterEqual(score, self.DUPLICATES_MIN_SCORE_WITH_PHOTOS)
# # Same flat, different agencies, texts and photos
# flats = self.load_files(
# "122509451@seloger",
# "127963747@seloger"
# )
# Same flat, different agencies, texts and photos
flats = self.load_files(
"122509451@seloger",
"127963747@seloger"
)
# score = duplicates.get_duplicate_score(
# flats[0], flats[1],
# self.IMAGE_CACHE, self.HASH_THRESHOLD
# )
# # Fix me : should be TestDuplicates.DUPLICATES_MIN_SCORE_WITH_PHOTOS
# self.assertGreaterEqual(score, 4)
score = duplicates.get_duplicate_score(
flats[0], flats[1],
self.IMAGE_CACHE, self.HASH_THRESHOLD
)
# Fix me : should be TestDuplicates.DUPLICATES_MIN_SCORE_WITH_PHOTOS
self.assertGreaterEqual(score, 4)
# # Really similar flats, but different
# flats = self.load_files(
# "123312807@seloger",
# "123314207@seloger"
# )
# Really similar flats, but different
flats = self.load_files(
"123312807@seloger",
"123314207@seloger"
)
# score = duplicates.get_duplicate_score(
# flats[0], flats[1],
# self.IMAGE_CACHE, self.HASH_THRESHOLD
# )
# self.assertLess(score, self.DUPLICATES_MIN_SCORE_WITH_PHOTOS)
score = duplicates.get_duplicate_score(
flats[0], flats[1],
self.IMAGE_CACHE, self.HASH_THRESHOLD
)
self.assertLess(score, self.DUPLICATES_MIN_SCORE_WITH_PHOTOS)
def run():
@ -443,13 +501,8 @@ def run():
"""
LOGGER.info("Running tests…")
try:
for testsuite in [
TestTexts,
TestPhoneNumbers,
TestImageCache,
TestDuplicates,
TestPhotos,
]:
for testsuite in [TestTexts, TestPhoneNumbers, TestImageCache,
TestDuplicates, TestPhotos]:
suite = unittest.TestLoader().loadTestsFromTestCase(testsuite)
result = unittest.TextTestRunner(verbosity=2).run(suite)
assert result.wasSuccessful()

View File

@ -3,7 +3,9 @@
This module contains basic utility functions, such as pretty printing of JSON
output, checking that a value is within a given interval etc.
"""
from __future__ import absolute_import, division, print_function, unicode_literals
from __future__ import (
absolute_import, division, print_function, unicode_literals
)
import datetime
import itertools
@ -13,13 +15,9 @@ import math
import re
import time
import imagehash
import mapbox
import requests
import unidecode
from flatisfy.constants import TimeToModes
LOGGER = logging.getLogger(__name__)
@ -27,23 +25,6 @@ LOGGER = logging.getLogger(__name__)
NAVITIA_ENDPOINT = "https://api.navitia.io/v1/coverage/fr-idf/journeys"
def next_weekday(d, weekday):
"""
Find datetime object for next given weekday.
From
https://stackoverflow.com/questions/6558535/find-the-date-for-the-first-monday-after-a-given-a-date.
:param d: Datetime to search from.
:param weekday: Weekday (0 for Monday, etc)
:returns: The datetime object for the next given weekday.
"""
days_ahead = weekday - d.weekday()
if days_ahead <= 0: # Target day already happened this week
days_ahead += 7
return d + datetime.timedelta(days_ahead)
def convert_arabic_to_roman(arabic):
"""
Convert an arabic literal to a roman one. Limits to 39, which is a rough
@ -59,18 +40,8 @@ def convert_arabic_to_roman(arabic):
return arabic
to_roman = {
1: "I",
2: "II",
3: "III",
4: "IV",
5: "V",
6: "VI",
7: "VII",
8: "VIII",
9: "IX",
10: "X",
20: "XX",
30: "XXX",
1: 'I', 2: 'II', 3: 'III', 4: 'IV', 5: 'V', 6: 'VI', 7: 'VII',
8: 'VIII', 9: 'IX', 10: 'X', 20: 'XX', 30: 'XXX'
}
roman_chars_list = []
count = 1
@ -79,7 +50,7 @@ def convert_arabic_to_roman(arabic):
if digit != 0:
roman_chars_list.append(to_roman[digit * count])
count *= 10
return "".join(roman_chars_list[::-1])
return ''.join(roman_chars_list[::-1])
def convert_arabic_to_roman_in_text(text):
@ -90,7 +61,11 @@ def convert_arabic_to_roman_in_text(text):
:returns: The corresponding text with roman literals converted to
arabic.
"""
return re.sub(r"(\d+)", lambda matchobj: convert_arabic_to_roman(matchobj.group(0)), text)
return re.sub(
r'(\d+)',
lambda matchobj: convert_arabic_to_roman(matchobj.group(0)),
text
)
def hash_dict(func):
@ -100,13 +75,11 @@ def hash_dict(func):
From https://stackoverflow.com/a/44776960.
"""
class HDict(dict):
"""
Transform mutable dictionnary into immutable. Useful to be compatible
with lru_cache
"""
def __hash__(self):
return hash(json.dumps(self))
@ -114,10 +87,17 @@ def hash_dict(func):
"""
The wrapped function
"""
args = tuple([HDict(arg) if isinstance(arg, dict) else arg for arg in args])
kwargs = {k: HDict(v) if isinstance(v, dict) else v for k, v in kwargs.items()}
args = tuple(
[
HDict(arg) if isinstance(arg, dict) else arg
for arg in args
]
)
kwargs = {
k: HDict(v) if isinstance(v, dict) else v
for k, v in kwargs.items()
}
return func(*args, **kwargs)
return wrapped
@ -125,17 +105,10 @@ class DateAwareJSONEncoder(json.JSONEncoder):
"""
Extend the default JSON encoder to serialize datetimes to iso strings.
"""
def default(self, o): # pylint: disable=locally-disabled,E0202
if isinstance(o, (datetime.date, datetime.datetime)):
return o.isoformat()
try:
return json.JSONEncoder.default(self, o)
except TypeError:
# Discard image hashes
if isinstance(o, imagehash.ImageHash):
return None
raise
def pretty_json(data):
@ -153,7 +126,9 @@ def pretty_json(data):
"toto": "ok"
}
"""
return json.dumps(data, cls=DateAwareJSONEncoder, indent=4, separators=(",", ": "), sort_keys=True)
return json.dumps(data, cls=DateAwareJSONEncoder,
indent=4, separators=(',', ': '),
sort_keys=True)
def batch(iterable, size):
@ -167,10 +142,7 @@ def batch(iterable, size):
sourceiter = iter(iterable)
while True:
batchiter = itertools.islice(sourceiter, size)
try:
yield itertools.chain([next(batchiter)], batchiter)
except StopIteration:
return
def is_within_interval(value, min_value=None, max_value=None):
@ -292,7 +264,10 @@ def distance(gps1, gps2):
long2 = math.radians(gps2[1])
# pylint: disable=locally-disabled,invalid-name
a = math.sin((lat2 - lat1) / 2.0) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin((long2 - long1) / 2.0) ** 2
a = (
math.sin((lat2 - lat1) / 2.0)**2 +
math.cos(lat1) * math.cos(lat2) * math.sin((long2 - long1) / 2.0)**2
)
c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
earth_radius = 6371000
@ -347,7 +322,7 @@ def merge_dicts(*args):
return merge_dicts(merged_flat, *args[2:])
def get_travel_time_between(latlng_from, latlng_to, mode, config):
def get_travel_time_between(latlng_from, latlng_to, config):
"""
Query the Navitia API to get the travel time between two points identified
by their latitude and longitude.
@ -355,7 +330,6 @@ def get_travel_time_between(latlng_from, latlng_to, mode, config):
:param latlng_from: A tuple of (latitude, longitude) for the starting
point.
:param latlng_to: A tuple of (latitude, longitude) for the destination.
:param mode: A TimeToMode enum value for the mode of transportation to use.
:return: A dict of the travel time in seconds and sections of the journey
with GeoJSON paths. Returns ``None`` if it could not fetch it.
@ -364,114 +338,63 @@ def get_travel_time_between(latlng_from, latlng_to, mode, config):
Uses the Navitia API. Requires a ``navitia_api_key`` field to be
filled-in in the ``config``.
"""
sections = []
travel_time = None
if mode == TimeToModes.PUBLIC_TRANSPORT:
# Check that Navitia API key is available
if config["navitia_api_key"]:
# Search route for next Monday at 8am to avoid looking for a route
# in the middle of the night if the fetch is done by night.
date_from = next_weekday(datetime.datetime.now(), 0).replace(
hour=8,
minute=0,
)
payload = {
"from": "%s;%s" % (latlng_from[1], latlng_from[0]),
"to": "%s;%s" % (latlng_to[1], latlng_to[0]),
"datetime": date_from.isoformat(),
"count": 1,
"datetime": datetime.datetime.now().isoformat(),
"count": 1
}
try:
# Do the query to Navitia API
req = requests.get(
NAVITIA_ENDPOINT,
params=payload,
auth=(config["navitia_api_key"], ""),
NAVITIA_ENDPOINT, params=payload,
auth=(config["navitia_api_key"], "")
)
req.raise_for_status()
journeys = req.json()["journeys"][0]
travel_time = journeys["durations"]["total"]
sections = []
for section in journeys["sections"]:
if section["type"] == "public_transport":
# Public transport
sections.append(
{
sections.append({
"geojson": section["geojson"],
"color": (section["display_informations"].get("color", None)),
}
"color": (
section["display_informations"].get("color", None)
)
})
elif section["type"] == "street_network":
# Walking
sections.append({"geojson": section["geojson"], "color": None})
sections.append({
"geojson": section["geojson"],
"color": None
})
else:
# Skip anything else
continue
except (
requests.exceptions.RequestException,
ValueError,
IndexError,
KeyError,
) as exc:
except (requests.exceptions.RequestException,
ValueError, IndexError, KeyError) as exc:
# Ignore any possible exception
LOGGER.warning(
"An exception occurred during travel time lookup on Navitia: %s.",
str(exc),
"An exception occurred during travel time lookup on "
"Navitia: %s.",
str(exc)
)
else:
LOGGER.warning(
"No API key available for travel time lookup. Please provide "
"a Navitia API key. Skipping travel time lookup."
)
elif mode in [TimeToModes.WALK, TimeToModes.BIKE, TimeToModes.CAR]:
MAPBOX_MODES = {
TimeToModes.WALK: "mapbox/walking",
TimeToModes.BIKE: "mapbox/cycling",
TimeToModes.CAR: "mapbox/driving",
}
# Check that Mapbox API key is available
if config["mapbox_api_key"]:
try:
service = mapbox.Directions(access_token=config["mapbox_api_key"])
origin = {
"type": "Feature",
"properties": {"name": "Start"},
"geometry": {
"type": "Point",
"coordinates": [latlng_from[1], latlng_from[0]],
},
}
destination = {
"type": "Feature",
"properties": {"name": "End"},
"geometry": {
"type": "Point",
"coordinates": [latlng_to[1], latlng_to[0]],
},
}
response = service.directions([origin, destination], MAPBOX_MODES[mode])
response.raise_for_status()
route = response.geojson()["features"][0]
# Fix longitude/latitude inversion in geojson output
geometry = route["geometry"]
geometry["coordinates"] = [(x[1], x[0]) for x in geometry["coordinates"]]
sections = [{"geojson": geometry, "color": "000"}]
travel_time = route["properties"]["duration"]
except (requests.exceptions.RequestException, IndexError, KeyError) as exc:
# Ignore any possible exception
LOGGER.warning(
"An exception occurred during travel time lookup on Mapbox: %s.",
str(exc),
)
else:
LOGGER.warning(
"No API key available for travel time lookup. Please provide "
"a Mapbox API key. Skipping travel time lookup."
)
if travel_time:
return {"time": travel_time, "sections": sections}
return {
"time": travel_time,
"sections": sections
}
return None
@ -479,7 +402,6 @@ def timeit(func):
"""
A decorator that logs how much time was spent in the function.
"""
def wrapped(*args, **kwargs):
"""
The wrapped function
@ -489,5 +411,4 @@ def timeit(func):
runtime = time.time() - before
LOGGER.info("%s -- Execution took %s seconds.", func.__name__, runtime)
return res
return wrapped

View File

@ -2,7 +2,9 @@
"""
This module contains the definition of the Bottle web app.
"""
from __future__ import absolute_import, division, print_function, unicode_literals
from __future__ import (
absolute_import, division, print_function, unicode_literals
)
import functools
import json
@ -23,12 +25,14 @@ class QuietWSGIRefServer(bottle.WSGIRefServer):
Quiet implementation of Bottle built-in WSGIRefServer, as `Canister` is
handling the logging through standard Python logging.
"""
# pylint: disable=locally-disabled,too-few-public-methods
quiet = True
def run(self, app):
app.log.info("Server is now up and ready! Listening on %s:%s." % (self.host, self.port))
app.log.info(
'Server is now up and ready! Listening on %s:%s.' %
(self.host, self.port)
)
super(QuietWSGIRefServer, self).run(app)
@ -38,9 +42,11 @@ def _serve_static_file(filename):
"""
return bottle.static_file(
filename,
root=os.path.join(os.path.dirname(os.path.realpath(__file__)), "static"),
root=os.path.join(
os.path.dirname(os.path.realpath(__file__)),
"static"
)
)
def get_app(config):
"""
@ -59,69 +65,61 @@ def get_app(config):
app.install(canister.Canister())
# Use DateAwareJSONEncoder to dump JSON strings
# From http://stackoverflow.com/questions/21282040/bottle-framework-how-to-return-datetime-in-json-response#comment55718456_21282666. pylint: disable=locally-disabled,line-too-long
app.install(bottle.JSONPlugin(json_dumps=functools.partial(json.dumps, cls=DateAwareJSONEncoder)))
app.install(
bottle.JSONPlugin(
json_dumps=functools.partial(json.dumps, cls=DateAwareJSONEncoder)
)
)
# Enable CORS
@app.hook("after_request")
@app.hook('after_request')
def enable_cors():
"""
Add CORS headers at each request.
"""
# The str() call is required as we import unicode_literal and WSGI
# headers list should have plain str type.
bottle.response.headers[str("Access-Control-Allow-Origin")] = str("*")
bottle.response.headers[str("Access-Control-Allow-Methods")] = str("PUT, GET, POST, DELETE, OPTIONS, PATCH")
bottle.response.headers[str("Access-Control-Allow-Headers")] = str(
"Origin, Accept, Content-Type, X-Requested-With, X-CSRF-Token"
bottle.response.headers[str('Access-Control-Allow-Origin')] = str('*')
bottle.response.headers[str('Access-Control-Allow-Methods')] = str(
'PUT, GET, POST, DELETE, OPTIONS, PATCH'
)
bottle.response.headers[str('Access-Control-Allow-Headers')] = str(
'Origin, Accept, Content-Type, X-Requested-With, X-CSRF-Token'
)
# API v1 routes
app.route("/api/v1", ["GET", "OPTIONS"], api_routes.index_v1)
app.route("/api/v1/time_to_places", ["GET", "OPTIONS"], api_routes.time_to_places_v1)
app.route("/api/v1/time_to_places", ["GET", "OPTIONS"],
api_routes.time_to_places_v1)
app.route("/api/v1/flats", ["GET", "OPTIONS"], api_routes.flats_v1)
app.route("/api/v1/flats/:flat_id", ["GET", "OPTIONS"], api_routes.flat_v1)
app.route("/api/v1/flats/:flat_id", ["PATCH", "OPTIONS"], api_routes.update_flat_v1)
app.route("/api/v1/flats/:flat_id", ["PATCH", "OPTIONS"],
api_routes.update_flat_v1)
app.route("/api/v1/ics/visits.ics", ["GET", "OPTIONS"], api_routes.ics_feed_v1)
app.route("/api/v1/ics/visits.ics", ["GET", "OPTIONS"],
api_routes.ics_feed_v1)
app.route("/api/v1/search", ["POST", "OPTIONS"], api_routes.search_v1)
app.route("/api/v1/search", "POST", api_routes.search_v1)
app.route("/api/v1/opendata", ["GET", "OPTIONS"], api_routes.opendata_index_v1)
app.route(
"/api/v1/opendata/postal_codes",
["GET", "OPTIONS"],
api_routes.opendata_postal_codes_v1,
)
app.route("/api/v1/metadata", ["GET", "OPTIONS"], api_routes.metadata_v1)
app.route("/api/v1/import", ["GET", "OPTIONS"], api_routes.import_v1)
app.route("/api/v1/opendata", "GET", api_routes.opendata_index_v1)
app.route("/api/v1/opendata/postal_codes", "GET",
api_routes.opendata_postal_codes_v1)
# Index
app.route("/", "GET", lambda: _serve_static_file("index.html"))
# Static files
app.route("/favicon.ico", "GET", lambda: _serve_static_file("favicon.ico"))
app.route("/favicon.ico", "GET",
lambda: _serve_static_file("favicon.ico"))
app.route(
"/assets/<filename:path>",
"GET",
lambda filename: _serve_static_file("/assets/{}".format(filename)),
"/assets/<filename:path>", "GET",
lambda filename: _serve_static_file("/assets/{}".format(filename))
)
app.route(
"/img/<filename:path>",
"GET",
lambda filename: _serve_static_file("/img/{}".format(filename)),
)
app.route(
"/.well-known/<filename:path>",
"GET",
lambda filename: _serve_static_file("/.well-known/{}".format(filename)),
)
app.route(
"/data/img/<filename:path>",
"GET",
lambda filename: bottle.static_file(filename, root=os.path.join(config["data_directory"], "images")),
"/img/<filename:path>", "GET",
lambda filename: _serve_static_file("/img/{}".format(filename))
)
return app

View File

@ -7,7 +7,9 @@ This module is heavily based on code from
[Bottle-SQLAlchemy](https://github.com/iurisilvio/bottle-sqlalchemy) which is
licensed under MIT license.
"""
from __future__ import absolute_import, division, print_function, unicode_literals
from __future__ import (
absolute_import, division, print_function, unicode_literals
)
import functools
import inspect
@ -20,8 +22,7 @@ class ConfigPlugin(object):
A Bottle plugin to automatically pass the config object to the routes
specifying they need it.
"""
name = "config"
name = 'config'
api = 2
KEYWORD = "config"
@ -40,7 +41,9 @@ class ConfigPlugin(object):
if not isinstance(other, ConfigPlugin):
continue
else:
raise bottle.PluginError("Found another conflicting Config plugin.")
raise bottle.PluginError(
"Found another conflicting Config plugin."
)
def apply(self, callback, route):
"""

View File

@ -7,7 +7,9 @@ This module is heavily based on code from
[Bottle-SQLAlchemy](https://github.com/iurisilvio/bottle-sqlalchemy) which is
licensed under MIT license.
"""
from __future__ import absolute_import, division, print_function, unicode_literals
from __future__ import (
absolute_import, division, print_function, unicode_literals
)
import inspect
@ -19,8 +21,7 @@ class DatabasePlugin(object):
A Bottle plugin to automatically pass an SQLAlchemy database session object
to the routes specifying they need it.
"""
name = "database"
name = 'database'
api = 2
KEYWORD = "db"
@ -40,7 +41,9 @@ class DatabasePlugin(object):
if not isinstance(other, DatabasePlugin):
continue
else:
raise bottle.PluginError("Found another conflicting Database plugin.")
raise bottle.PluginError(
"Found another conflicting Database plugin."
)
def apply(self, callback, route):
"""
@ -61,7 +64,6 @@ class DatabasePlugin(object):
if self.KEYWORD not in callback_args:
# If no need for a db session, call the route callback
return callback
def wrapper(*args, **kwargs):
"""
Wrap the callback in a call to get_session.
@ -70,7 +72,6 @@ class DatabasePlugin(object):
# Get a db session and pass it to the callback
kwargs[self.KEYWORD] = session
return callback(*args, **kwargs)
return wrapper

View File

@ -161,10 +161,3 @@ export const doSearch = function (query, callback) {
console.error('Unable to perform search: ' + ex)
})
}
export const getMetadata = function (callback) {
fetch('/api/v1/metadata', { credentials: 'same-origin' })
.then(response => response.json())
.then(json => callback(json.data))
.catch(ex => console.error('Unable to fetch application metadata: ' + ex))
}

View File

@ -16,7 +16,7 @@
<style>
body {
margin: 0 auto;
/* max-width: 75em; */
max-width: 75em;
font-family: "Helvetica", "Arial", sans-serif;
line-height: 1.5;
padding: 4em 1em;

View File

@ -1,398 +0,0 @@
<template>
<div>
<template v-if="isLoading">
<p>{{ $t("common.loading") }}</p>
</template>
<div class="grid" v-else-if="flat && timeToPlaces">
<div class="left-panel">
<h2>
(<!--
--><router-link :to="{ name: 'status', params: { status: flat.status }}"><!--
-->{{ flat.status ? capitalize($t("status." + flat.status)) : '' }}<!--
--></router-link><!--
-->) {{ flat.title }} [{{ flat.id.split("@")[1] }}]
<span class="expired">{{ flat.is_expired ? '[' + $t('common.expired') + ']' : '' }}</span>
</h2>
<div class="grid">
<div class="left-panel">
<span>
{{ flat.cost | cost(flat.currency) }}
<template v-if="flat.utilities === 'included'">
{{ $t("flatsDetails.utilities_included") }}
</template>
<template v-else-if="flat.utilities === 'excluded'">
{{ $t("flatsDetails.utilities_excluded") }}
</template>
</span>
<span v-if="flat.flatisfy_postal_code.postal_code">
à {{ flat.flatisfy_postal_code.name }} ({{ flat.flatisfy_postal_code.postal_code }})
</span>
</div>
<span class="right-panel right">
<template v-if="flat.area"><span>{{flat.area}} m<sup>2</sup></span></template><template v-if="flat.rooms"><span>, {{flat.rooms}} {{ $tc("flatsDetails.rooms", flat.rooms) }}</span></template><template v-if="flat.bedrooms"><span>/ {{flat.bedrooms}} {{ $tc("flatsDetails.bedrooms", flat.bedrooms) }}</span></template>
</span>
</div>
<div>
<template v-if="flat.photos && flat.photos.length > 0">
<Slider :photos="flat.photos"></Slider>
</template>
</div>
<div>
<h3>{{ $t("flatsDetails.Description") }}</h3>
<p>{{ flat.text }}</p>
<p class="right">{{ flat.location }}</p>
<p>{{ $t("flatsDetails.First_posted") }} {{ flat.date ? flat.date.fromNow() : '?' }}.</p>
</div>
<div>
<h3>{{ $t("flatsDetails.Details") }}</h3>
<table>
<tr v-for="(value, key) in flat.details">
<th>{{ key }}</th>
<td>{{ value }}</td>
</tr>
</table>
</div>
<div>
<h3>{{ $t("flatsDetails.Metadata") }}</h3>
<table>
<tr>
<th>
{{ $t("flatsDetails.postal_code") }}
</th>
<td>
<template v-if="flat.flatisfy_postal_code.postal_code">
{{ flat.flatisfy_postal_code.name }} ({{ flat.flatisfy_postal_code.postal_code }})
</template>
<template v-else>
?
</template>
</td>
</tr>
<tr v-if="displayedStations">
<th>
{{ $t("flatsDetails.nearby_stations") }}
</th>
<td>
{{ displayedStations }}
</td>
</tr>
<tr v-if="Object.keys(flat.flatisfy_time_to).length">
<th>
{{ $t("flatsDetails.Times_to") }}
</th>
<td>
<ul class="time_to_list">
<li v-for="(time_to, place) in flat.flatisfy_time_to" :key="place">
{{ place }}: {{ humanizeTimeTo(time_to["time"]) }}
</li>
</ul>
</td>
</tr>
<tr>
<th>
{{ $t("flatsDetails.SquareMeterCost") }}
</th>
<td>
{{ flat.sqCost }} {{ flat.currency }}
</td>
</tr>
</table>
</div>
<div>
<h3>{{ $t("flatsDetails.Location") }}</h3>
<FlatsMap :flats="flatMarker" :places="timeToPlaces" :journeys="journeys"></FlatsMap>
</div>
<div>
<h3>{{ $t("flatsDetails.Notes") }}</h3>
<form v-on:submit="updateFlatNotes">
<textarea ref="notesTextarea" rows="10" :v-model="flat.notes"></textarea>
<p class="right"><input type="submit" :value="$t('flatsDetails.Save')"/></p>
</form>
</div>
</div>
<div class="right-panel">
<h3>{{ $t("flatsDetails.Contact") }}</h3>
<div class="contact">
<template v-if="flat.phone">
<p v-for="phoneNumber in flat.phone.split(',')">
<a :href="'tel:+33' + normalizePhoneNumber(phoneNumber)">{{ phoneNumber }}</a>
</p>
</template>
<template v-if="flat.urls.length == 1">
<a :href="flat.urls[0]" target="_blank">
{{ $tc("common.Original_post", 1) }}
<i class="fa fa-external-link" aria-hidden="true"></i>
</a>
</template>
<template v-else-if="flat.urls.length > 1">
<p>{{ $tc("common.Original_post", flat.urls.length) }}
<ul>
<li v-for="(url, index) in flat.urls">
<a :href="url" target="_blank">
{{ $tc("common.Original_post", 1) }} {{ index + 1 }}
<i class="fa fa-external-link" aria-hidden="true"></i>
</a>
</li>
</ul>
</p>
</template>
</div>
<h3>{{ $t("flatsDetails.Visit") }}</h3>
<div class="visit">
<flat-pickr
:value="flatpickrValue"
:config="flatpickrConfig"
:placeholder="$t('flatsDetails.setDateOfVisit')"
/>
</div>
<h3>{{ $t("common.Actions") }}</h3>
<nav>
<ul>
<template v-if="flat.status !== 'user_deleted'">
<Notation :flat="flat"></Notation>
<li>
<button v-on:click="updateFlatStatus('user_deleted')" class="fullButton">
<i class="fa fa-trash" aria-hidden="true"></i>
{{ $t("common.Remove") }}
</button>
</li>
</template>
<template v-else>
<li>
<button v-on:click="updateFlatStatus('new')" class="fullButton">
<i class="fa fa-undo" aria-hidden="true"></i>
{{ $t("common.Restore") }}
</button>
</li>
</template>
</ul>
</nav>
</div>
</div>
</div>
</template>
<script>
import flatPickr from 'vue-flatpickr-component'
import moment from 'moment'
import 'font-awesome-webpack'
import 'flatpickr/dist/flatpickr.css'
import FlatsMap from '../components/flatsmap.vue'
import Slider from '../components/slider.vue'
import Notation from '../components/notation.vue'
import { capitalize } from '../tools'
export default {
components: {
FlatsMap,
Slider,
flatPickr,
Notation
},
created () {
this.fetchData()
},
data () {
return {
// TODO: Flatpickr locale
'overloadNotation': null,
'flatpickrConfig': {
static: true,
altFormat: 'h:i K, M j, Y',
altInput: true,
enableTime: true,
onChange: selectedDates => this.updateFlatVisitDate(selectedDates.length > 0 ? selectedDates[0] : null)
}
}
},
props: ['flat'],
computed: {
isLoading () {
return this.$store.getters.isLoading
},
flatMarker () {
return this.$store.getters.flatsMarkers(this.$router, flat => flat.id === this.flat.id)
},
'flatpickrValue' () {
if (this.flat && this.flat.visit_date) {
return this.flat.visit_date.local().format()
}
return null
},
timeToPlaces () {
return this.$store.getters.timeToPlaces(this.flat.flatisfy_constraint)
},
notation () {
if (this.overloadNotation) {
return this.overloadNotation
}
return this.flat.notation
},
journeys () {
if (Object.keys(this.flat.flatisfy_time_to).length > 0) {
const journeys = []
for (const place in this.flat.flatisfy_time_to) {
this.flat.flatisfy_time_to[place].sections.forEach(
section => journeys.push({
geojson: section.geojson,
options: {
color: section.color ? ('#' + section.color) : '#2196f3',
dashArray: section.color ? 'none' : '2, 10'
}
})
)
}
return journeys
}
return []
},
displayedStations () {
if (this.flat.flatisfy_stations.length > 0) {
const stationsNames = this.flat.flatisfy_stations.map(station => station.name)
return stationsNames.join(', ')
} else {
return null
}
}
},
watch: {
flat: 'fetchData'
},
methods: {
fetchData () {
this.$store.dispatch('getAllTimeToPlaces')
},
updateFlatStatus (status) {
this.$store.dispatch('updateFlatStatus', { flatId: this.flat.id, newStatus: status })
},
updateFlatNotes () {
const notes = this.$refs.notesTextarea.value
this.$store.dispatch(
'updateFlatNotes',
{ flatId: this.flat.id, newNotes: notes }
)
},
updateFlatVisitDate (date) {
if (date) {
date = moment(date).utc().format()
}
this.$store.dispatch(
'updateFlatVisitDate',
{ flatId: this.flat.id, newVisitDate: date }
)
},
humanizeTimeTo (time) {
const minutes = Math.floor(time.as('minutes'))
return minutes + ' ' + this.$tc('common.mins', minutes)
},
normalizePhoneNumber (phoneNumber) {
phoneNumber = phoneNumber.replace(/ /g, '')
phoneNumber = phoneNumber.replace(/\./g, '')
return phoneNumber
},
capitalize: capitalize
}
}
</script>
<style scoped>
.expired {
font-weight: bold;
text-transform: uppercase;
}
@media screen and (min-width: 768px) {
.grid {
display: grid;
grid-gap: 50px;
grid-template-columns: 75fr 25fr;
}
.left-panel {
grid-column: 1;
grid-row: 1;
}
.right-panel {
grid-column: 2;
grid-row: 1;
}
}
.left-panel textarea {
width: 100%;
}
.right {
text-align: right;
}
nav ul {
list-style-type: none;
padding-left: 1em;
}
.contact {
padding-left: 1em;
}
.right-panel li {
margin-bottom: 1em;
margin-top: 1em;
}
button {
cursor: pointer;
width: 75%;
padding: 0.3em;
font-size: 0.9em;
}
table {
table-layout: fixed;
}
td {
word-wrap: break-word;
word-break: break-all;
white-space: normal;
}
.time_to_list {
margin: 0;
padding-left: 0;
list-style-position: outside;
list-style-type: none;
}
@media screen and (max-width: 767px) {
.right-panel nav {
text-align: center;
}
.fullButton {
width: 100%;
}
}
</style>

View File

@ -1,26 +1,21 @@
<template lang="html">
<div class="full">
<v-map v-if="bounds" :zoom="zoom.defaultZoom" :bounds="bounds" :min-zoom="zoom.minZoom" :max-zoom="zoom.maxZoom" v-on:click="$emit('select-flat', null)" @update:bounds="bounds = $event">
<v-map :zoom="zoom.defaultZoom" :center="center" :bounds="bounds" :min-zoom="zoom.minZoom" :max-zoom="zoom.maxZoom">
<v-tilelayer :url="tiles.url" :attribution="tiles.attribution"></v-tilelayer>
<v-marker-cluster>
<template v-for="marker in flats">
<v-marker :lat-lng="{ lat: marker.gps[0], lng: marker.gps[1] }" :icon="icons.flat" v-on:click="$emit('select-flat', marker.flatId)">
<!-- <v-popup :content="marker.content"></v-popup> -->
<v-marker :lat-lng="{ lat: marker.gps[0], lng: marker.gps[1] }" :icon="icons.flat">
<v-popup :content="marker.content"></v-popup>
</v-marker>
</template>
</v-marker-cluster>
<v-marker-cluster>
<template v-for="(place_gps, place_name) in places">
<v-marker :lat-lng="{ lat: place_gps[0], lng: place_gps[1] }" :icon="icons.place">
<v-tooltip :content="place_name"></v-tooltip>
</v-marker>
</template>
</v-marker-cluster>
<template v-for="journey in journeys">
<v-geojson-layer :geojson="journey.geojson" :options="Object.assign({}, defaultGeoJSONOptions, journey.options)"></v-geojson-layer>
</template>
</v-map>
<div v-else>Nothing to display yet</div>
</div>
</template>
@ -36,13 +31,10 @@ L.Icon.Default.mergeOptions({
})
import 'leaflet/dist/leaflet.css'
import 'leaflet.markercluster/dist/MarkerCluster.css'
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
require('leaflet.icon.glyph')
import { LMap, LTileLayer, LMarker, LTooltip, LPopup, LGeoJson } from 'vue2-leaflet'
import Vue2LeafletMarkerCluster from 'vue2-leaflet-markercluster'
import Vue2Leaflet from 'vue2-leaflet'
export default {
data () {
@ -54,11 +46,11 @@ export default {
fillColor: '#e4ce7f',
fillOpacity: 1
},
bounds: [[40.91351257612758, -7.580566406250001], [51.65892664880053, 12.0849609375]],
center: null,
zoom: {
defaultZoom: 6,
defaultZoom: 13,
minZoom: 5,
maxZoom: 20
maxZoom: 17
},
tiles: {
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
@ -75,27 +67,25 @@ export default {
},
components: {
'v-map': LMap,
'v-tilelayer': LTileLayer,
'v-marker': LMarker,
'v-marker-cluster': Vue2LeafletMarkerCluster,
'v-tooltip': LTooltip,
'v-popup': LPopup,
'v-geojson-layer': LGeoJson
'v-map': Vue2Leaflet.Map,
'v-tilelayer': Vue2Leaflet.TileLayer,
'v-marker': Vue2Leaflet.Marker,
'v-tooltip': Vue2Leaflet.Tooltip,
'v-popup': Vue2Leaflet.Popup,
'v-geojson-layer': Vue2Leaflet.GeoJSON
},
watch: {
flats: 'computeBounds',
places: 'computeBounds'
},
computed: {
bounds () {
let bounds = []
this.flats.forEach(flat => bounds.push(flat.gps))
Object.keys(this.places).forEach(place => bounds.push(this.places[place]))
methods: {
computeBounds (newData, oldData) {
if (this.flats.length && JSON.stringify(newData) !== JSON.stringify(oldData)) {
const allBounds = []
this.flats.forEach(flat => allBounds.push(flat.gps))
Object.keys(this.places).forEach(place => allBounds.push(this.places[place]))
this.bounds = allBounds.length ? L.latLngBounds(allBounds) : undefined
if (bounds.length > 0) {
bounds = L.latLngBounds(bounds)
return bounds
} else {
return null
}
}
},

View File

@ -48,7 +48,62 @@
</tr>
</thead>
<tbody>
<FlatsTableLine :flat="flat" :showNotationColumn="showNotationColumn" :showNotes="showNotes" v-for="flat in sortedFlats" :key="flat.id"></FlatsTableLine>
<tr v-for="flat in sortedFlats" :key="flat.id">
<td v-if="showNotationColumn">
<template v-for="n in range(flat.notation)">
<i class="fa fa-star" aria-hidden="true" :title="capitalize($t('status.followed'))"></i>
</template>
</td>
<td class="no-padding">
<router-link class="fill" :to="{name: 'details', params: {id: flat.id}}">
<template v-if="!showNotationColumn" v-for="n in range(flat.notation)">
<i class="fa fa-star" aria-hidden="true" :title="capitalize($t('status.followed'))"></i>
</template>
[{{ flat.id.split("@")[1] }}] {{ flat.title }}
<template v-if="flat.photos && flat.photos.length > 0">
<br/>
<img :src="flat.photos[0].url"/>
</template>
<template v-if="showNotes">
<br/>
<pre>{{ flat.notes }}</pre>
</template>
</router-link>
</td>
<td>{{ flat.area }} </td>
<td>
{{ flat.rooms ? flat.rooms : '?'}}
</td>
<td>
{{ flat.cost }} {{ flat.currency }}
<template v-if="flat.utilities == 'included'">
{{ $t("flatsDetails.utilities_included") }}
</template>
<template v-else-if="flat.utilities == 'excluded'">
{{ $t("flatsDetails.utilities_excluded") }}
</template>
</td>
<td>
{{ flat.sqCost }} {{ flat.currency }}
</td>
<td>
<router-link :to="{name: 'details', params: {id: flat.id}}" :aria-label="$t('common.More_about') + ' ' + flat.id" :title="$t('common.More_about') + ' ' + flat.id">
<i class="fa fa-eye" aria-hidden="true"></i>
</router-link>
<a :href="flat.urls[0]" :aria-label="$t('common.Original_post_for') + ' ' + flat.id" :title="$t('common.Original_post_for') + ' ' + flat.id" target="_blank">
<i class="fa fa-external-link" aria-hidden="true"></i>
</a>
<button v-if="flat.status !== 'user_deleted'" v-on:click="updateFlatStatus(flat.id, 'user_deleted')" :aria-label="$t('common.Remove') + ' ' + flat.id" :title="$t('common.Remove') + ' ' + flat.id">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
<button v-else v-on:click="updateFlatStatus(flat.id, 'new')" :aria-label="$t('common.Restore') + ' ' + flat.id" :title="$t('common.Restore') + ' ' + flat.id">
<i class="fa fa-undo" aria-hidden="true"></i>
</button>
</td>
</tr>
</tbody>
</table>
</template>
@ -56,15 +111,9 @@
<script>
import 'font-awesome-webpack'
import FlatsTableLine from './flatstableline.vue'
import { capitalize } from '../tools'
import { capitalize, range } from '../tools'
export default {
components: {
FlatsTableLine
},
data () {
return {
sortBy: this.initialSortBy,
@ -118,6 +167,9 @@ export default {
},
methods: {
updateFlatStatus (id, status) {
this.$store.dispatch('updateFlatStatus', { flatId: id, newStatus: status })
},
updateSortBy (field) {
if (this.sortBy === field) {
if (this.sortOrder === 'up') {
@ -129,7 +181,8 @@ export default {
this.sortBy = field
}
},
capitalize: capitalize
capitalize: capitalize,
range: range
}
}
</script>

View File

@ -1,100 +0,0 @@
<template>
<tr>
<td v-if="showNotationColumn">
<Notation :flat="flat" :title="capitalizedStatus"></Notation>
</td>
<td class="no-padding">
<Notation v-if="!showNotationColumn" :flat="flat" :title="capitalizedStatus"></Notation>
<router-link class="fill" :to="{name: 'details', params: {id: flat.id}}">
[{{ flat.id.split("@")[1] }}]
<span class="expired">{{ flat.is_expired ? "[" + $t("common.expired") + "]" : null }}</span>
{{ flat.title }}
<template v-if="photo">
<br/>
<img :src="photo" height="200" style="max-width: 25vw" />
</template>
<template v-if="showNotes">
<br/>
<pre>{{ flat.notes }}</pre>
</template>
</router-link>
</td>
<td>{{ flat.area }} </td>
<td>
{{ flat.rooms ? flat.rooms : '?'}}
</td>
<td>
{{ flat.cost | cost(flat.currency) }}
<template v-if="flat.utilities == 'included'">
{{ $t("flatsDetails.utilities_included") }}
</template>
<template v-else-if="flat.utilities == 'excluded'">
{{ $t("flatsDetails.utilities_excluded") }}
</template>
</td>
<td>
{{ flat.sqCost }} {{ flat.currency }}
</td>
<td>
<router-link :to="{name: 'details', params: {id: flat.id}}" :aria-label="$t('common.More_about') + ' ' + flat.id" :title="$t('common.More_about') + ' ' + flat.id">
<i class="fa fa-eye" aria-hidden="true"></i>
</router-link>
<a :href="flat.urls[0]" :aria-label="$t('common.Original_post_for') + ' ' + flat.id" :title="$t('common.Original_post_for') + ' ' + flat.id" target="_blank">
<i class="fa fa-external-link" aria-hidden="true"></i>
</a>
<button v-if="flat.status !== 'user_deleted'" v-on:click="updateFlatStatus(flat.id, 'user_deleted')" :aria-label="$t('common.Remove') + ' ' + flat.id" :title="$t('common.Remove') + ' ' + flat.id">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
<button v-else v-on:click="updateFlatStatus(flat.id, 'new')" :aria-label="$t('common.Restore') + ' ' + flat.id" :title="$t('common.Restore') + ' ' + flat.id">
<i class="fa fa-undo" aria-hidden="true"></i>
</button>
</td>
</tr>
</template>
<script>
import { capitalize } from '../tools'
import Notation from '../components/notation.vue'
export default {
props: {
flat: Object,
showNotationColumn: Boolean,
showNotes: Boolean
},
components: {
Notation
},
computed: {
capitalizedStatus () {
return capitalize(this.$t('status.followed'))
},
photo () {
if (this.flat.photos && this.flat.photos.length > 0) {
if (this.flat.photos[0].local) {
return `/data/img/${this.flat.photos[0].local}`
}
return this.flat.photos[0].url
}
return null
}
},
methods: {
updateFlatStatus (id, status) {
this.$store.dispatch('updateFlatStatus', { flatId: id, newStatus: status })
}
}
}
</script>
<style scoped>
.expired {
font-weight: bold;
text-transform: uppercase;
}
</style>

View File

@ -1,68 +0,0 @@
<template>
<div>
<template v-for="n in range(5)">
<button v-bind:key="n" v-on:mouseover="handleHover(n)" v-on:mouseout="handleOut()" v-on:click="updateNotation(n)">
<i class="fa" v-bind:class="{'fa-star': n < notation, 'fa-star-o': n >= notation}" aria-hidden="true"></i>
</button>
</template>
</div>
</template>
<script>
import { range } from '../tools'
import 'flatpickr/dist/flatpickr.css'
export default {
data () {
return {
'overloadNotation': null
}
},
props: ['flat'],
computed: {
notation () {
if (this.overloadNotation) {
return this.overloadNotation
}
return this.flat.notation
}
},
methods: {
updateNotation (notation) {
notation = notation + 1
if (notation === this.flat.notation) {
this.flat.notation = 0
this.$store.dispatch('updateFlatNotation', { flatId: this.flat.id, newNotation: 0 })
this.$store.dispatch('updateFlatStatus', { flatId: this.flat.id, newStatus: 'new' })
} else {
this.flat.notation = notation
this.$store.dispatch('updateFlatNotation', { flatId: this.flat.id, newNotation: notation })
this.$store.dispatch('updateFlatStatus', { flatId: this.flat.id, newStatus: 'followed' })
}
},
handleHover (n) {
this.overloadNotation = n + 1
},
handleOut () {
this.overloadNotation = null
},
range: range
}
}
</script>
<style scoped>
button {
border: none;
width: auto;
background-color: transparent;
}
</style>

View File

@ -1,15 +1,15 @@
<template>
<div @keydown="closeModal">
<isotope ref="cpt" :options="isotopeOptions" v-images-loaded:on.progress="layout" :list="photos">
<div v-for="(photo, index) in photosURLOrLocal" :key="photo">
<img :src="photo" v-on:click="openModal(index)"/>
<div v-for="(photo, index) in photos" :key="photo.url">
<img :src="photo.url" v-on:click="openModal(index)"/>
</div>
</isotope>
<div class="modal" ref="modal" :aria-label="$t('slider.Fullscreen_photo')" role="dialog">
<span class="close"><button v-on:click="closeModal" :title="$t('common.Close')" :aria-label="$t('common.Close')">&times;</button></span>
<img class="modal-content" :src="photosURLOrLocal[modalImgIndex]">
<img class="modal-content" :src="photos[modalImgIndex].url">
</div>
</div>
</template>
@ -27,17 +27,6 @@ export default {
isotope
},
computed: {
photosURLOrLocal () {
return this.photos.map(photo => {
if (photo.local) {
return `/data/img/${photo.local}`
}
return photo.url
})
}
},
created () {
window.addEventListener('keydown', event => {
if (!this.isModalOpen) {

View File

@ -13,14 +13,10 @@ export default {
'Close': 'Close',
'sortUp': 'Sort in ascending order',
'sortDown': 'Sort in descending order',
'mins': 'min | mins',
'Unknown': 'Unknown',
'expired': 'expired'
'mins': 'min | mins'
},
home: {
'new_available_flats': 'New available flats',
'Last_update': 'Last update:',
'show_expired_flats': 'Show expired flats'
'new_available_flats': 'New available flats'
},
flatListing: {
'no_available_flats': 'No available flats.',
@ -42,15 +38,12 @@ export default {
'utilities_included': '(utilities included)',
'utilities_excluded': '(utilities excluded)',
'Description': 'Description',
'First_posted': 'First posted',
'Details': 'Details',
'Metadata': 'Metadata',
'postal_code': 'Postal code',
'nearby_stations': 'Nearby stations',
'Times_to': 'Times to',
'Location': 'Location',
'Notes': 'Notes',
'Save': 'Save',
'Contact': 'Contact',
'Visit': 'Visit',
'setDateOfVisit': 'Set date of visit',

View File

@ -1,75 +0,0 @@
export default {
common: {
'flats': 'appartement | appartements',
'loading': 'Chargement…',
'Actions': 'Actions',
'More_about': 'Plus sur',
'Remove': 'Enlever',
'Restore': 'Remettre',
'Original_post': 'Annonce originale | Annonces originales',
'Original_post_for': 'Annonce originale pour',
'Follow': 'Suivre',
'Unfollow': 'Arrêter de suivre',
'Close': 'Fermer',
'sortUp': 'Trier par ordre croissant',
'sortDown': 'Trier par ordre décroissant',
'mins': 'min | mins',
'Unknown': 'Inconnu',
'expired': 'expiré'
},
home: {
'new_available_flats': 'Nouveaux appartements disponibles',
'Last_update': 'Dernière mise à jour :',
'show_expired_flats': 'Montrer les annonces expirées'
},
flatListing: {
'no_available_flats': 'Pas d\'appartement disponible.',
'no_matching_flats': 'Pas d\'appartement correspondant.'
},
menu: {
'available_flats': 'Appartements disponibles',
'followed_flats': 'Appartements suivis',
'by_status': 'Appartements par statut',
'search': 'Rechercher'
},
flatsDetails: {
'Notation': 'Note',
'Title': 'Titre',
'Area': 'Surface',
'Rooms': 'Pièces',
'Cost': 'Coût',
'SquareMeterCost': 'Coût / m²',
'utilities_included': '(charges comprises)',
'utilities_excluded': '(charges non comprises)',
'Description': 'Description',
'First_posted': 'Posté pour la première fois',
'Details': 'Détails',
'Metadata': 'Metadonnées',
'postal_code': 'Code postal',
'nearby_stations': 'Stations proches',
'Times_to': 'Temps jusqu\'à',
'Location': 'Localisation',
'Notes': 'Notes',
'Save': 'Sauvegarder',
'Contact': 'Contact',
'Visit': 'Visite',
'setDateOfVisit': 'Entrer une date de visite',
'no_phone_found': 'Pas de numéro de téléphone trouvé',
'rooms': 'pièce | pièces',
'bedrooms': 'chambre | chambres'
},
status: {
'new': 'nouveau',
'followed': 'suivi',
'ignored': 'ignoré',
'user_deleted': 'effacé',
'duplicate': 'en double'
},
slider: {
'Fullscreen_photo': 'Photo en plein écran'
},
search: {
'input_placeholder': 'Tapez n\'importe quoi à rechercher…',
'Search': 'Chercher !'
}
}

View File

@ -1,10 +1,8 @@
import Vue from 'vue'
import VueI18n from 'vue-i18n'
import moment from 'moment'
// Import translations
import en from './en'
import fr from './fr'
Vue.use(VueI18n)
@ -34,8 +32,7 @@ export function getBrowserLocales () {
}
const messages = {
'en': en,
'fr': fr
'en': en
}
const locales = getBrowserLocales()
@ -49,9 +46,6 @@ for (var i = 0; i < locales.length; ++i) {
}
}
// Set the locale for Moment.js
moment.locale(locale)
export default new VueI18n({
locale: locale,
messages

View File

@ -3,12 +3,9 @@ import Vue from 'vue'
import i18n from './i18n'
import router from './router'
import store from './store'
import { costFilter } from './tools'
import App from './components/app.vue'
Vue.filter('cost', costFilter)
new Vue({
i18n,
router,

View File

@ -50,11 +50,5 @@ export default {
api.doSearch(query, flats => {
commit(types.REPLACE_FLATS, { flats })
})
},
getMetadata ({ commit }) {
commit(types.IS_LOADING)
api.getMetadata(metadata => {
commit(types.RECEIVE_METADATA, { metadata })
})
}
}

View File

@ -1,79 +1,64 @@
import { findFlatGPS, costFilter } from '../tools'
import { findFlatGPS } from '../tools'
export default {
allFlats: (state) => state.flats,
allFlats: state => state.flats,
flat: (state, getters) => (id) =>
state.flats.find((flat) => flat.id === id),
flat: (state, getters) => id => state.flats.find(flat => flat.id === id),
isLoading: (state) => state.loading > 0,
isLoading: state => state.loading > 0,
inseeCodesFlatsBuckets: (state, getters) => (filter) => {
const buckets = {}
postalCodesFlatsBuckets: (state, getters) => filter => {
const postalCodeBuckets = {}
state.flats.forEach((flat) => {
state.flats.forEach(flat => {
if (!filter || filter(flat)) {
const insee = flat.flatisfy_postal_code.insee_code
if (!buckets[insee]) {
buckets[insee] = {
name: flat.flatisfy_postal_code.name,
flats: []
const postalCode = flat.flatisfy_postal_code.postal_code
if (!postalCodeBuckets[postalCode]) {
postalCodeBuckets[postalCode] = {
'name': flat.flatisfy_postal_code.name,
'flats': []
}
}
buckets[insee].flats.push(flat)
postalCodeBuckets[postalCode].flats.push(flat)
}
})
return buckets
return postalCodeBuckets
},
flatsMarkers: (state, getters) => (router, filter) => {
const markers = []
state.flats.forEach((flat) => {
state.flats.forEach(flat => {
if (filter && filter(flat)) {
const gps = findFlatGPS(flat)
if (gps) {
const previousMarker = markers.find(
(marker) =>
marker.gps[0] === gps[0] && marker.gps[1] === gps[1]
const previousMarkerIndex = markers.findIndex(
marker => marker.gps[0] === gps[0] && marker.gps[1] === gps[1]
)
if (previousMarker) {
// randomize position a bit
// gps[0] += (Math.random() - 0.5) / 500
// gps[1] += (Math.random() - 0.5) / 500
}
const href = router.resolve({
name: 'details',
params: { id: flat.id }
}).href
const cost = flat.cost
? costFilter(flat.cost, flat.currency)
: ''
const href = router.resolve({ name: 'details', params: { id: flat.id }}).href
if (previousMarkerIndex !== -1) {
markers[previousMarkerIndex].content += '<br/><a href="' + href + '">' + flat.title + '</a>'
} else {
markers.push({
title: '',
content:
'<a href="' +
href +
'">' +
flat.title +
'</a>' +
cost,
gps: gps,
flatId: flat.id
'title': '',
'content': '<a href="' + href + '">' + flat.title + '</a>',
'gps': gps
})
}
}
}
})
return markers
},
allTimeToPlaces: (state) => {
allTimeToPlaces: state => {
const places = {}
Object.keys(state.timeToPlaces).forEach((constraint) => {
Object.keys(state.timeToPlaces).forEach(constraint => {
const constraintTimeToPlaces = state.timeToPlaces[constraint]
Object.keys(constraintTimeToPlaces).forEach((name) => {
Object.keys(constraintTimeToPlaces).forEach(name => {
places[name] = constraintTimeToPlaces[name]
})
})
@ -82,7 +67,5 @@ export default {
timeToPlaces: (state, getters) => (constraintName) => {
return state.timeToPlaces[constraintName]
},
metadata: (state) => state.metadata
}
}

View File

@ -5,5 +5,4 @@ export const UPDATE_FLAT_NOTES = 'UPDATE_FLAT_NOTES'
export const UPDATE_FLAT_NOTATION = 'UPDATE_FLAT_NOTATION'
export const UPDATE_FLAT_VISIT_DATE = 'UPDATE_FLAT_VISIT_DATE'
export const RECEIVE_TIME_TO_PLACES = 'RECEIVE_TIME_TO_PLACES'
export const RECEIVE_METADATA = 'RECEIVE_METADATA'
export const IS_LOADING = 'IS_LOADING'

View File

@ -5,7 +5,6 @@ import * as types from './mutations-types'
export const state = {
flats: [],
timeToPlaces: [],
metadata: [],
loading: 0
}
@ -59,10 +58,6 @@ export const mutations = {
state.timeToPlaces = timeToPlaces
state.loading -= 1
},
[types.RECEIVE_METADATA] (state, { metadata }) {
state.metadata = metadata
state.loading -= 1
},
[types.IS_LOADING] (state) {
state.loading += 1
}

View File

@ -1,10 +1,8 @@
export function findFlatGPS (flat) {
let gps
if (flat.flatisfy_position) {
gps = [flat.flatisfy_position.lat, flat.flatisfy_position.lng]
} else if (flat.flatisfy_stations && flat.flatisfy_stations.length > 0) {
// Try to push a marker based on stations
if (flat.flatisfy_stations && flat.flatisfy_stations.length > 0) {
gps = [0.0, 0.0]
flat.flatisfy_stations.forEach(station => {
gps = [gps[0] + station.gps[0], gps[1] + station.gps[1]]
@ -25,18 +23,3 @@ export function capitalize (string) {
export function range (n) {
return [...Array(n).keys()]
}
export function costFilter (value, currency) {
if (!value) {
return 'N/A'
}
if (currency === 'EUR') {
currency = ' €'
}
var valueStr = value.toString()
valueStr = ' '.repeat((3 + valueStr.length) % 3) + valueStr
return valueStr.match(/.{1,3}/g).join('.') + currency
}

View File

@ -3,18 +3,216 @@
<template v-if="isLoading">
<p>{{ $t("common.loading") }}</p>
</template>
<Flat :flat="flat"></Flat>
<div class="grid" v-else-if="flat && timeToPlaces">
<div class="left-panel">
<h2>
(<!--
--><router-link :to="{ name: 'status', params: { status: flat.status }}"><!--
-->{{ flat.status ? capitalize($t("status." + flat.status)) : '' }}<!--
--></router-link><!--
-->) {{ flat.title }} [{{ flat.id.split("@")[1] }}]
</h2>
<div class="grid">
<div class="left-panel">
<p>
{{ flat.cost }} {{ flat.currency }}
<template v-if="flat.utilities === 'included'">
{{ $t("flatsDetails.utilities_included") }}
</template>
<template v-else-if="flat.utilities === 'excluded'">
{{ $t("flatsDetails.utilities_excluded") }}
</template>
</p>
</div>
<p class="right-panel right">
{{ flat.area ? flat.area : '?' }} m<sup>2</sup>,
{{ flat.rooms ? flat.rooms : '?' }} {{ $tc("flatsDetails.rooms", flat.rooms) }} /
{{ flat.bedrooms ? flat.bedrooms : '?' }} {{ $tc("flatsDetails.bedrooms", flat.bedrooms) }}
</p>
</div>
<div>
<template v-if="flat.photos && flat.photos.length > 0">
<Slider :photos="flat.photos"></Slider>
</template>
</div>
<div>
<h3>{{ $t("flatsDetails.Description") }}</h3>
<p>{{ flat.text }}</p>
<p class="right">{{ flat.location }}</p>
<p>First posted {{ flat.date ? flat.date.fromNow() : '?' }}.</p>
</div>
<div>
<h3>{{ $t("flatsDetails.Details") }}</h3>
<table>
<tr v-for="(value, key) in flat.details">
<th>{{ key }}</th>
<td>{{ value }}</td>
</tr>
</table>
</div>
<div>
<h3>{{ $t("flatsDetails.Metadata") }}</h3>
<table>
<tr>
<th>
{{ $t("flatsDetails.postal_code") }}
</th>
<td>
<template v-if="flat.flatisfy_postal_code.postal_code">
{{ flat.flatisfy_postal_code.name }} ({{ flat.flatisfy_postal_code.postal_code }})
</template>
<template v-else>
?
</template>
</td>
</tr>
<tr>
<th>
{{ $t("flatsDetails.nearby_stations") }}
</th>
<td>
<template v-if="displayedStations">
{{ displayedStations }}
</template>
<template v-else>
?
</template>
</td>
</tr>
<tr>
<th>
{{ $t("flatsDetails.Times_to") }}
</th>
<td>
<template v-if="Object.keys(flat.flatisfy_time_to).length">
<ul class="time_to_list">
<li v-for="(time_to, place) in flat.flatisfy_time_to" :key="place">
{{ place }}: {{ humanizeTimeTo(time_to["time"]) }}
</li>
</ul>
</template>
<template v-else>
?
</template>
</td>
</tr>
<tr>
<th>
{{ $t("flatsDetails.SquareMeterCost") }}
</th>
<td>
{{ flat.sqCost }} {{ flat.currency }}
</td>
</tr>
</table>
</div>
<div>
<h3>{{ $t("flatsDetails.Location") }}</h3>
<FlatsMap :flats="flatMarkers" :places="timeToPlaces" :journeys="journeys"></FlatsMap>
</div>
<div>
<h3>Notes</h3>
<form v-on:submit="updateFlatNotes">
<textarea ref="notesTextarea" rows="10">{{ flat.notes }}</textarea>
<p class="right"><input type="submit" value="Save"/></p>
</form>
</div>
</div>
<div class="right-panel">
<h3>{{ $t("flatsDetails.Contact") }}</h3>
<div class="contact">
<p>
<template v-if="flat.phone">
<template v-for="phoneNumber in flat.phone.split(',')">
<a :href="'tel:+33' + normalizePhoneNumber(phoneNumber)">{{ phoneNumber }}</a><br/>
</template>
</template>
<template v-else>
{{ $t("flatsDetails.no_phone_found") }}
</template>
</p>
<p>{{ $tc("common.Original_post", 42) }}
<ul>
<li v-for="(url, index) in flat.urls">
<a :href="url">
{{ $tc("common.Original_post", 1) }} {{ index + 1 }}
<i class="fa fa-external-link" aria-hidden="true"></i>
</a>
</li>
</ul>
</p>
</div>
<h3>{{ $t("flatsDetails.Visit") }}</h3>
<div class="visit">
<flat-pickr
:value="flatpickrValue"
:config="flatpickrConfig"
:placeholder="$t('flatsDetails.setDateOfVisit')"
/>
</div>
<h3>{{ $t("common.Actions") }}</h3>
<nav>
<ul>
<template v-if="flat.status !== 'user_deleted'">
<li ref="notationButton">
<template v-for="n in range(notation)">
<button class="btnIcon" v-on:mouseover="handleNotationHover(n)" v-on:mouseout="handleNotationOut()" v-on:click="updateFlatNotation(n)">
<i class="fa fa-star" aria-hidden="true"></i>
</button>
</template>
<template v-for="n in range(5 - notation)">
<button class="btnIcon" v-on:mouseover="handleNotationHover(notation + n)" v-on:mouseout="handleNotationOut()" v-on:click="updateFlatNotation(notation + n)">
<i class="fa fa-star-o" aria-hidden="true"></i>
</button>
</template>
</li>
<li>
<button v-on:click="updateFlatStatus('user_deleted')" class="fullButton">
<i class="fa fa-trash" aria-hidden="true"></i>
{{ $t("common.Remove") }}
</button>
</li>
</template>
<template v-else>
<li>
<button v-on:click="updateFlatStatus('new')" class="fullButton">
<i class="fa fa-undo" aria-hidden="true"></i>
{{ $t("common.Restore") }}
</button>
</li>
</template>
</ul>
</nav>
</div>
</div>
</div>
</template>
<script>
import flatPickr from 'vue-flatpickr-component'
import moment from 'moment'
import 'font-awesome-webpack'
import 'flatpickr/dist/flatpickr.css'
import Flat from '../components/flat.vue'
import FlatsMap from '../components/flatsmap.vue'
import Slider from '../components/slider.vue'
import { capitalize, range } from '../tools'
export default {
components: {
Flat
FlatsMap,
Slider,
flatPickr
},
created () {
document.title = this.title // Set title
@ -33,6 +231,20 @@ export default {
}
},
data () {
return {
// TODO: Flatpickr locale
'overloadNotation': null,
'flatpickrConfig': {
static: true,
altFormat: 'h:i K, M j, Y',
altInput: true,
enableTime: true,
onChange: selectedDates => this.updateFlatVisitDate(selectedDates.length > 0 ? selectedDates[0] : null)
}
}
},
computed: {
isLoading () {
return this.$store.getters.isLoading
@ -40,16 +252,199 @@ export default {
title () {
return 'Flatisfy - ' + this.$route.params.id
},
flatMarkers () {
return this.$store.getters.flatsMarkers(this.$router, flat => flat.id === this.$route.params.id)
},
flat () {
return this.$store.getters.flat(this.$route.params.id)
},
'flatpickrValue' () {
if (this.flat && this.flat.visit_date) {
return this.flat.visit_date.local().format()
}
return null
},
timeToPlaces () {
return this.$store.getters.timeToPlaces(this.flat.flatisfy_constraint)
},
notation () {
if (this.overloadNotation) {
return this.overloadNotation
}
return this.flat.notation
},
journeys () {
if (Object.keys(this.flat.flatisfy_time_to).length > 0) {
const journeys = []
for (const place in this.flat.flatisfy_time_to) {
this.flat.flatisfy_time_to[place].sections.forEach(
section => journeys.push({
geojson: section.geojson,
options: {
color: section.color ? ('#' + section.color) : '#2196f3',
dashArray: section.color ? 'none' : '2, 10'
}
})
)
}
return journeys
}
return []
},
displayedStations () {
if (this.flat.flatisfy_stations.length > 0) {
const stationsNames = this.flat.flatisfy_stations.map(station => station.name)
return stationsNames.join(', ')
} else {
return null
}
}
},
methods: {
fetchData () {
this.$store.dispatch('getFlat', { flatId: this.$route.params.id })
this.$store.dispatch('getAllTimeToPlaces')
},
updateFlatNotation (notation) {
notation = notation + 1
if (notation === this.flat.notation) {
this.$store.dispatch('updateFlatNotation', { flatId: this.$route.params.id, newNotation: 0 })
this.$store.dispatch('updateFlatStatus', { flatId: this.$route.params.id, newStatus: 'new' })
} else {
this.$store.dispatch('updateFlatNotation', { flatId: this.$route.params.id, newNotation: notation })
this.$store.dispatch('updateFlatStatus', { flatId: this.$route.params.id, newStatus: 'followed' })
}
},
updateFlatStatus (status) {
this.$store.dispatch('updateFlatStatus', { flatId: this.$route.params.id, newStatus: status })
},
updateFlatNotes () {
const notes = this.$refs.notesTextarea.value
this.$store.dispatch(
'updateFlatNotes',
{ flatId: this.$route.params.id, newNotes: notes }
)
},
updateFlatVisitDate (date) {
if (date) {
date = moment(date).utc().format()
}
this.$store.dispatch(
'updateFlatVisitDate',
{ flatId: this.$route.params.id, newVisitDate: date }
)
},
humanizeTimeTo (time) {
const minutes = Math.floor(time.as('minutes'))
return minutes + ' ' + this.$tc('common.mins', minutes)
},
handleNotationHover (n) {
this.overloadNotation = n + 1
},
handleNotationOut () {
this.overloadNotation = null
},
normalizePhoneNumber (phoneNumber) {
phoneNumber = phoneNumber.replace(/ /g, '')
phoneNumber = phoneNumber.replace(/\./g, '')
return phoneNumber
},
capitalize: capitalize,
range: range
}
}
</script>
<style scoped>
@media screen and (min-width: 768px) {
.grid {
display: grid;
grid-gap: 50px;
grid-template-columns: 75fr 25fr;
}
.left-panel {
grid-column: 1;
grid-row: 1;
}
.right-panel {
grid-column: 2;
grid-row: 1;
}
}
.left-panel textarea {
width: 100%;
}
.right {
text-align: right;
}
nav ul {
list-style-type: none;
padding-left: 1em;
}
.contact {
padding-left: 1em;
}
.right-panel li {
margin-bottom: 1em;
margin-top: 1em;
}
button {
cursor: pointer;
width: 75%;
padding: 0.3em;
font-size: 0.9em;
}
table {
table-layout: fixed;
}
td {
word-wrap: break-word;
word-break: break-all;
white-space: normal;
}
.time_to_list {
margin: 0;
padding-left: 0;
list-style-position: outside;
list-style-type: none;
}
.btnIcon {
border: none;
width: auto;
background-color: transparent;
}
@media screen and (max-width: 767px) {
.right-panel nav {
text-align: center;
}
.fullButton {
width: 100%;
}
}
</style>

View File

@ -1,30 +1,13 @@
<template>
<div class="flex-row">
<div class="flex" style="overflow: auto;">
<FlatsMap :flats="flatsMarkers" :places="timeToPlaces" v-on:select-flat="selectFlat($event)"></FlatsMap>
<h2>
{{ $t("home.new_available_flats") }}
<template v-if="lastUpdate">
<label class="show-last-update">
{{ $t("home.Last_update") }} {{ lastUpdate.fromNow() }}
</label>
</template>
<label class="show-expired-flats-label">
<input type="checkbox" class="show-expired-flats-checkbox" v-model="showExpiredFlats" />
{{ $t("home.show_expired_flats") }}
</label>
</h2>
<div>
<FlatsMap :flats="flatsMarkers" :places="timeToPlaces"></FlatsMap>
<template v-if="Object.keys(inseeCodesFlatsBuckets).length > 0">
<template v-for="(insee_code_data, insee_code) in inseeCodesFlatsBuckets">
<h3>
{{ insee_code_data.name || $t('common.Unknown') }}
<span v-if="insee_code !== 'undefined'">
({{ insee_code }})
</span>
- {{ insee_code_data.flats.length }} {{ $tc("common.flats", insee_code_data.flats.length) }}
</h3>
<FlatsTable :flats="insee_code_data.flats" :key="insee_code"></FlatsTable>
<h2>{{ $t("home.new_available_flats") }}</h2>
<template v-if="Object.keys(postalCodesFlatsBuckets).length > 0">
<template v-for="(postal_code_data, postal_code) in postalCodesFlatsBuckets">
<h3>{{ postal_code_data.name }} ({{ postal_code }}) - {{ postal_code_data.flats.length }} {{ $tc("common.flats", postal_code_data.flats.length) }}</h3>
<FlatsTable :flats="postal_code_data.flats"></FlatsTable>
</template>
</template>
<template v-else-if="isLoading">
@ -34,23 +17,16 @@
<p>{{ $t("flatListing.no_available_flats") }}</p>
</template>
</div>
<div v-if="selectedFlat" class="flex">
<Flat :flat="selectedFlat"></Flat>
</div>
</div>
</template>
<script>
import FlatsMap from '../components/flatsmap.vue'
import FlatsTable from '../components/flatstable.vue'
import Flat from '../components/flat.vue'
import moment from 'moment'
export default {
components: {
FlatsMap,
FlatsTable,
Flat
FlatsTable
},
created () {
@ -60,83 +36,21 @@ export default {
this.$store.dispatch('getAllFlats')
// Fetch time to places when the component is created
this.$store.dispatch('getAllTimeToPlaces')
// Fetch application metadata when the component is created
this.$store.dispatch('getMetadata')
},
data () {
return {
showExpiredFlats: false,
selectedFlat: undefined
}
},
methods: {
selectFlat: async function (flatId) {
if (flatId) {
await this.$store.dispatch('getFlat', { flatId })
this.selectedFlat = await this.$store.getters.flat(flatId)
} else {
this.selectedFlat = undefined
}
}
},
computed: {
inseeCodesFlatsBuckets () {
return this.$store.getters.inseeCodesFlatsBuckets(flat =>
flat.status === 'new' &&
(this.showExpiredFlats || !flat.is_expired)
)
postalCodesFlatsBuckets () {
return this.$store.getters.postalCodesFlatsBuckets(flat => flat.status === 'new')
},
flatsMarkers () {
return this.$store.getters.flatsMarkers(this.$router, flat =>
flat.status === 'new' &&
(this.showExpiredFlats || !flat.is_expired)
)
return this.$store.getters.flatsMarkers(this.$router, flat => flat.status === 'new')
},
timeToPlaces () {
return this.$store.getters.allTimeToPlaces
},
lastUpdate () {
var metadata = this.$store.getters.metadata
var lastUpdateDate = moment.unix(metadata['last_update'])
if (!lastUpdateDate.isValid()) {
lastUpdateDate = 0
}
return lastUpdateDate
},
isLoading () {
return this.$store.getters.isLoading
}
}
}
</script>
<style scoped>
h2 {
display: flex;
justify-content: space-between;
}
.flex-row {
display:flex;
}
.flex {
flex: 1;
}
table {
margin-left: 0;
margin-right: 0;
width: 100%;
}
.show-expired-flats-label {
font-weight: initial;
font-size: initial;
}
.show-last-update {
font-weight: initial;
font-size: initial;
}
</style>

View File

@ -12,10 +12,10 @@
<template v-if="isLoading">
<p>{{ $t("common.loading") }}</p>
</template>
<template v-else-if="Object.keys(inseeCodesFlatsBuckets).length > 0">
<template v-for="(insee_code_data, insee_code) in inseeCodesFlatsBuckets">
<h3>{{ insee_code_data.name }} ({{ insee_code }}) - {{ insee_code_data.flats.length }} {{ $tc("common.flats", insee_code_data.flats.length) }}</h3>
<FlatsTable :flats="insee_code_data.flats"></FlatsTable>
<template v-else-if="Object.keys(postalCodesFlatsBuckets).length > 0">
<template v-for="(postal_code_data, postal_code) in postalCodesFlatsBuckets">
<h3>{{ postal_code_data.name }} ({{ postal_code }}) - {{ postal_code_data.flats.length }} {{ $tc("common.flats", postal_code_data.flats.length) }}</h3>
<FlatsTable :flats="postal_code_data.flats"></FlatsTable>
</template>
</template>
<template v-else>
@ -51,12 +51,12 @@ export default {
},
computed: {
inseeCodesFlatsBuckets () {
postalCodesFlatsBuckets () {
if (!this.$route.query.query || this.loading) {
return {}
}
return this.$store.getters.inseeCodesFlatsBuckets(
return this.$store.getters.postalCodesFlatsBuckets(
flat => flat.status !== 'duplicate' && flat.status !== 'ignored' && flat.status !== 'user_deleted'
)
},

View File

@ -17,11 +17,11 @@
<template v-if="isLoading">
<p>{{ $t("common.loading") }}</p>
</template>
<template v-else-if="Object.keys(inseeCodesFlatsBuckets).length">
<template v-for="(insee_code_data, insee_code) in inseeCodesFlatsBuckets">
<h3>{{ insee_code_data.name }} ({{ insee_code }}) - {{ insee_code_data.flats.length }} {{ $tc("common.flats", insee_code_data.flats.length) }}</h3>
<template v-else-if="Object.keys(postalCodesFlatsBuckets).length">
<template v-for="(postal_code_data, postal_code) in postalCodesFlatsBuckets">
<h3>{{ postal_code_data.name }} ({{ postal_code }}) - {{ postal_code_data.flats.length }} {{ $tc("common.flats", postal_code_data.flats.length) }}</h3>
<FlatsTable
:flats="insee_code_data.flats"
:flats="postal_code_data.flats"
:showNotationColumn="$route.params.status === 'followed'"
:showNotes="$route.params.status === 'followed'"
:initialSortBy="$route.params.status === 'followed' ? 'notation' : undefined"
@ -81,8 +81,8 @@ export default {
},
computed: {
inseeCodesFlatsBuckets () {
return this.$store.getters.inseeCodesFlatsBuckets(flat => flat.status === this.$route.params.status)
postalCodesFlatsBuckets () {
return this.$store.getters.postalCodesFlatsBuckets(flat => flat.status === this.$route.params.status)
},
title () {
return 'Flatisfy - ' + capitalize(this.$t('status.' + this.$route.params.status))

View File

@ -2,23 +2,21 @@
"""
This module contains the definition of the web app API routes.
"""
from __future__ import absolute_import, division, print_function, unicode_literals
from __future__ import (
absolute_import, division, print_function, unicode_literals
)
import datetime
import itertools
import json
import logging
import re
import os
import bottle
import vobject
import flatisfy.data
from flatisfy.models import flat as flat_model
from flatisfy.models import postal_code
from flatisfy.models.postal_code import PostalCode
from flatisfy import cmds
FILTER_RE = re.compile(r"filter\[([A-z0-9_]+)\]")
@ -61,24 +59,26 @@ def _JSONApiSpec(query, model, default_sorting=None):
# Handle pagination according to JSON API spec
page_number, page_size = 0, None
try:
if "page[size]" in query:
page_size = int(query["page[size]"])
if 'page[size]' in query:
page_size = int(query['page[size]'])
assert page_size > 0
if "page[number]" in query:
page_number = int(query["page[number]"])
if 'page[number]' in query:
page_number = int(query['page[number]'])
assert page_number >= 0
except (AssertionError, ValueError):
raise ValueError("Invalid pagination provided.")
# Handle sorting according to JSON API spec
sorting = []
if "sort" in query:
for index in query["sort"].split(","):
if 'sort' in query:
for index in query['sort'].split(','):
try:
sort_field = getattr(model, index.lstrip("-"))
sort_field = getattr(model, index.lstrip('-'))
except AttributeError:
raise ValueError("Invalid sorting key provided: {}.".format(index))
if index.startswith("-"):
raise ValueError(
"Invalid sorting key provided: {}.".format(index)
)
if index.startswith('-'):
sort_field = sort_field.desc()
sorting.append(sort_field)
# Default sorting options
@ -86,7 +86,11 @@ def _JSONApiSpec(query, model, default_sorting=None):
try:
sorting.append(getattr(model, default_sorting))
except AttributeError:
raise ValueError("Invalid default sorting key provided: {}.".format(default_sorting))
raise ValueError(
"Invalid default sorting key provided: {}.".format(
default_sorting
)
)
return filters, page_number, page_size, sorting
@ -105,22 +109,22 @@ def _serialize_flat(flat, config):
postal_codes = {}
for constraint_name, constraint in config["constraints"].items():
postal_codes[constraint_name] = flatisfy.data.load_data(PostalCode, constraint, config)
postal_codes[constraint_name] = flatisfy.data.load_data(
PostalCode, constraint, config
)
try:
assert flat["flatisfy_position"]
assert flat["flatisfy_postal_code"]
lat = flat["flatisfy_position"]["lat"]
lng = flat["flatisfy_position"]["lng"]
postal_code_data = next(
x for x in postal_codes.get(flat["flatisfy_constraint"], []) if x.lat == lat and x.lng == lng
x
for x in postal_codes.get(flat["flatisfy_constraint"], [])
if x.postal_code == flat["flatisfy_postal_code"]
)
logging.warn(f"{postal_code_data.name}, {lat}, {lng}")
flat["flatisfy_postal_code"] = {
"postal_code": postal_code_data.postal_code,
"insee_code": postal_code_data.insee_code,
"postal_code": flat["flatisfy_postal_code"],
"name": postal_code_data.name,
"gps": (postal_code_data.lat, postal_code_data.lng),
"gps": (postal_code_data.lat, postal_code_data.lng)
}
except (AssertionError, StopIteration):
flat["flatisfy_postal_code"] = {}
@ -142,8 +146,7 @@ def index_v1():
"flat": "/api/v1/flat/:id",
"search": "/api/v1/search",
"ics": "/api/v1/ics/visits.ics",
"time_to_places": "/api/v1/time_to_places",
"metadata": "/api/v1/metadata",
"time_to_places": "/api/v1/time_to_places"
}
@ -174,32 +177,36 @@ def flats_v1(config, db):
:return: The available flats objects in a JSON ``data`` dict.
"""
if bottle.request.method == "OPTIONS":
if bottle.request.method == 'OPTIONS':
# CORS
return ""
return ''
try:
try:
filters, page_number, page_size, sorting = _JSONApiSpec(
bottle.request.query, flat_model.Flat, default_sorting="cost"
bottle.request.query,
flat_model.Flat,
default_sorting='cost'
)
except ValueError as exc:
return JSONError(400, str(exc))
# Build flat list
db_query = db.query(flat_model.Flat).filter_by(**filters).order_by(*sorting)
db_query = (
db.query(flat_model.Flat).filter_by(**filters).order_by(*sorting)
)
flats = [
_serialize_flat(flat, config)
for flat in itertools.islice(
db_query,
page_number * page_size if page_size else None,
page_number * page_size + page_size if page_size else None,
page_number * page_size + page_size if page_size else None
)
]
return {
"data": flats,
"page": page_number,
"items_per_page": page_size if page_size else len(flats),
"items_per_page": page_size if page_size else len(flats)
}
except Exception as exc: # pylint: disable= broad-except
return JSONError(500, str(exc))
@ -215,7 +222,7 @@ def flat_v1(flat_id, config, db):
:return: The flat object in a JSON ``data`` dict.
"""
if bottle.request.method == "OPTIONS":
if bottle.request.method == 'OPTIONS':
# CORS
return {}
@ -225,7 +232,9 @@ def flat_v1(flat_id, config, db):
if not flat:
return JSONError(404, "No flat with id {}.".format(flat_id))
return {"data": _serialize_flat(flat, config)}
return {
"data": _serialize_flat(flat, config)
}
except Exception as exc: # pylint: disable= broad-except
return JSONError(500, str(exc))
@ -249,7 +258,7 @@ def update_flat_v1(flat_id, config, db):
:return: The new flat object in a JSON ``data`` dict.
"""
if bottle.request.method == "OPTIONS":
if bottle.request.method == 'OPTIONS':
# CORS
return {}
@ -263,9 +272,14 @@ def update_flat_v1(flat_id, config, db):
for key, value in json_body.items():
setattr(flat, key, value)
except ValueError as exc:
return JSONError(400, "Invalid payload provided: {}.".format(str(exc)))
return JSONError(
400,
"Invalid payload provided: {}.".format(str(exc))
)
return {"data": _serialize_flat(flat, config)}
return {
"data": _serialize_flat(flat, config)
}
except Exception as exc: # pylint: disable= broad-except
return JSONError(500, str(exc))
@ -281,15 +295,20 @@ def time_to_places_v1(config):
:return: The JSON dump of the places to compute time to (dict of places
names mapped to GPS coordinates).
"""
if bottle.request.method == "OPTIONS":
if bottle.request.method == 'OPTIONS':
# CORS
return {}
try:
places = {}
for constraint_name, constraint in config["constraints"].items():
places[constraint_name] = {k: v["gps"] for k, v in constraint["time_to"].items()}
return {"data": places}
places[constraint_name] = {
k: v["gps"]
for k, v in constraint["time_to"].items()
}
return {
"data": places
}
except Exception as exc: # pylint: disable= broad-except
return JSONError(500, str(exc))
@ -324,7 +343,7 @@ def search_v1(db, config):
:return: The matching flat objects in a JSON ``data`` dict.
"""
if bottle.request.method == "OPTIONS":
if bottle.request.method == 'OPTIONS':
# CORS
return {}
@ -336,25 +355,30 @@ def search_v1(db, config):
try:
filters, page_number, page_size, sorting = _JSONApiSpec(
bottle.request.query, flat_model.Flat, default_sorting="cost"
bottle.request.query,
flat_model.Flat,
default_sorting='cost'
)
except ValueError as exc:
return JSONError(400, str(exc))
flats_db_query = flat_model.Flat.search_query(db, query).filter_by(**filters).order_by(*sorting)
flats_db_query = (flat_model.Flat
.search_query(db, query)
.filter_by(**filters)
.order_by(*sorting))
flats = [
_serialize_flat(flat, config)
for flat in itertools.islice(
flats_db_query,
page_number * page_size if page_size else None,
page_number * page_size + page_size if page_size else None,
page_number * page_size + page_size if page_size else None
)
]
return {
"data": flats,
"page": page_number,
"items_per_page": page_size if page_size else len(flats),
"items_per_page": page_size if page_size else len(flats)
}
except Exception as exc: # pylint: disable= broad-except
return JSONError(500, str(exc))
@ -370,33 +394,35 @@ def ics_feed_v1(config, db):
:return: The ICS feed for the visits.
"""
if bottle.request.method == "OPTIONS":
if bottle.request.method == 'OPTIONS':
# CORS
return {}
cal = vobject.iCalendar()
try:
flats_with_visits = db.query(flat_model.Flat).filter(flat_model.Flat.visit_date.isnot(None))
flats_with_visits = db.query(flat_model.Flat).filter(
flat_model.Flat.visit_date.isnot(None)
)
for flat in flats_with_visits:
vevent = cal.add("vevent")
vevent.add("dtstart").value = flat.visit_date
vevent.add("dtend").value = flat.visit_date + datetime.timedelta(hours=1)
vevent.add("summary").value = "Visit - {}".format(flat.title)
description = "{} (area: {}, cost: {} {})\n{}#/flat/{}\n".format(
flat.title,
flat.area,
flat.cost,
flat.currency,
config["website_url"],
flat.id,
vevent = cal.add('vevent')
vevent.add('dtstart').value = flat.visit_date
vevent.add('dtend').value = (
flat.visit_date + datetime.timedelta(hours=1)
)
description += "\n{}\n".format(flat.text)
if flat.notes:
description += "\n{}\n".format(flat.notes)
vevent.add('summary').value = 'Visit - {}'.format(flat.title)
vevent.add("description").value = description
description = (
'{} (area: {}, cost: {} {})\n{}#/flat/{}\n'.format(
flat.title, flat.area, flat.cost, flat.currency,
config['website_url'], flat.id
)
)
description += '\n{}\n'.format(flat.text)
if flat.notes:
description += '\n{}\n'.format(flat.notes)
vevent.add('description').value = description
except Exception: # pylint: disable= broad-except
pass
@ -411,11 +437,9 @@ def opendata_index_v1():
GET /api/v1/opendata
"""
if bottle.request.method == "OPTIONS":
# CORS
return {}
return {"postal_codes": "/api/v1/opendata/postal_codes"}
return {
"postal_codes": "/api/v1/opendata/postal_codes"
}
def opendata_postal_codes_v1(db):
@ -446,79 +470,32 @@ def opendata_postal_codes_v1(db):
:return: The postal codes data from opendata.
"""
if bottle.request.method == "OPTIONS":
if bottle.request.method == 'OPTIONS':
# CORS
return {}
try:
try:
filters, page_number, page_size, sorting = _JSONApiSpec(
bottle.request.query, PostalCode, default_sorting="postal_code"
bottle.request.query,
PostalCode,
default_sorting='postal_code'
)
except ValueError as exc:
return JSONError(400, str(exc))
db_query = db.query(PostalCode).filter_by(**filters).order_by(*sorting)
postal_codes = [
x.json_api_repr()
for x in itertools.islice(
x.json_api_repr() for x in itertools.islice(
db_query,
page_number * page_size if page_size else None,
page_number * page_size + page_size if page_size else None,
page_number * page_size + page_size if page_size else None
)
]
return {
"data": postal_codes,
"page": page_number,
"items_per_page": page_size if page_size else len(postal_codes),
"items_per_page": page_size if page_size else len(postal_codes)
}
except Exception as exc: # pylint: disable= broad-except
return JSONError(500, str(exc))
def metadata_v1(config):
"""
API v1 metadata of the application.
Example::
GET /api/v1/metadata
:return: The application metadata.
"""
if bottle.request.method == "OPTIONS":
# CORS
return {}
try:
last_update = None
try:
ts_file = os.path.join(config["data_directory"], "timestamp")
last_update = os.path.getmtime(ts_file)
except OSError:
pass
return {"data": {"last_update": last_update}}
except Exception as exc: # pylint: disable= broad-except
return JSONError(500, str(exc))
def import_v1(config):
"""
API v1 import new flats.
Example::
GET /api/v1/import
:return: The new flats.
"""
if bottle.request.method == "OPTIONS":
# CORS
return {}
try:
flats_id = cmds.import_and_filter(config, False, True)
return {"flats": flats_id}
except Exception as exc: # pylint: disable= broad-except
return JSONError(500, str(exc))

View File

@ -1,2 +0,0 @@
#!/bin/sh -ev
python -m flatisfy import --config config.json --new-only -v "$@"

View File

@ -1 +0,0 @@
Generic single-database configuration.

View File

@ -1,68 +0,0 @@
from __future__ import with_statement
from alembic import context
from sqlalchemy import engine_from_config, pool
from logging.config import fileConfig
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = None
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -1,24 +0,0 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@ -1,24 +0,0 @@
"""Add is_expired
Revision ID: 8155b83242eb
Revises:
Create Date: 2018-10-16 22:51:25.442678
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "8155b83242eb"
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
op.add_column("flats", sa.Column("is_expired", sa.Boolean(), default=False))
def downgrade():
op.drop_column("flats", "is_expired")

View File

@ -1,24 +0,0 @@
"""Add flat INSEE column
Revision ID: 9e58c66f1ac1
Revises: d21933db9ad8
Create Date: 2021-02-08 16:31:18.961186
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "9e58c66f1ac1"
down_revision = "d21933db9ad8"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("postal_codes", sa.Column("insee_code", sa.String()))
def downgrade():
op.drop_column("postal_codes", "insee_code")

View File

@ -1,69 +0,0 @@
"""Add flat position column
Revision ID: d21933db9ad8
Revises: 8155b83242eb
Create Date: 2021-02-08 16:26:37.190842
"""
from alembic import op
import sqlalchemy as sa
import sqlalchemy.types as types
import json
class StringyJSON(types.TypeDecorator):
"""
Stores and retrieves JSON as TEXT for SQLite.
From
https://avacariu.me/articles/2016/compiling-json-as-text-for-sqlite-with-sqlalchemy.
.. note ::
The associated field is immutable. That is, changes to the data
(typically, changing the value of a dict field) will not trigger an
update on the SQL side upon ``commit`` as the reference to the object
will not have been updated. One should force the update by forcing an
update of the reference (by performing a ``copy`` operation on the dict
for instance).
"""
impl = types.TEXT
def process_bind_param(self, value, dialect):
"""
Process the bound param, serialize the object to JSON before saving
into database.
"""
if value is not None:
value = json.dumps(value)
return value
def process_result_value(self, value, dialect):
"""
Process the value fetched from the database, deserialize the JSON
string before returning the object.
"""
if value is not None:
value = json.loads(value)
return value
# TypeEngine.with_variant says "use StringyJSON instead when
# connecting to 'sqlite'"
# pylint: disable=locally-disabled,invalid-name
MagicJSON = types.JSON().with_variant(StringyJSON, "sqlite")
# revision identifiers, used by Alembic.
revision = "d21933db9ad8"
down_revision = "8155b83242eb"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("flats", sa.Column("flatisfy_position", MagicJSON, default=False))
def downgrade():
op.drop_column("flats", "flatisfy_position")

View File

@ -1,24 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright(C) 2014 Bezleputh
#
# This file is part of a woob module.
#
# This woob module is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This woob module is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this woob module. If not, see <http://www.gnu.org/licenses/>.
from .module import ExplorimmoModule
__all__ = ['ExplorimmoModule']

View File

@ -1,92 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright(C) 2014 Bezleputh
#
# This file is part of a woob module.
#
# This woob module is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This woob module is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this woob module. If not, see <http://www.gnu.org/licenses/>.
from woob.browser import PagesBrowser, URL
from woob.capabilities.housing import (TypeNotSupported, POSTS_TYPES,
HOUSE_TYPES)
from woob.tools.compat import urlencode
from .pages import CitiesPage, SearchPage, HousingPage, HousingPage2, PhonePage
class ExplorimmoBrowser(PagesBrowser):
BASEURL = 'https://immobilier.lefigaro.fr'
cities = URL('/rest/locations\?q=(?P<city>.*)', CitiesPage)
search = URL('/annonces/resultat/annonces.html\?(?P<query>.*)', SearchPage)
housing_html = URL('/annonces/annonce-(?P<_id>.*).html', HousingPage)
phone = URL('/rest/classifieds/(?P<_id>.*)/phone', PhonePage)
housing = URL('/rest/classifieds/(?P<_id>.*)',
'/rest/classifieds/\?(?P<js_datas>.*)', HousingPage2)
TYPES = {POSTS_TYPES.RENT: 'location',
POSTS_TYPES.SALE: 'vente',
POSTS_TYPES.FURNISHED_RENT: 'location',
POSTS_TYPES.VIAGER: 'vente'}
RET = {HOUSE_TYPES.HOUSE: 'Maison',
HOUSE_TYPES.APART: 'Appartement',
HOUSE_TYPES.LAND: 'Terrain',
HOUSE_TYPES.PARKING: 'Parking',
HOUSE_TYPES.OTHER: 'Divers'}
def get_cities(self, pattern):
return self.cities.open(city=pattern).get_cities()
def search_housings(self, type, cities, nb_rooms, area_min, area_max,
cost_min, cost_max, house_types, advert_types):
if type not in self.TYPES:
raise TypeNotSupported()
ret = []
if type == POSTS_TYPES.VIAGER:
ret = ['Viager']
else:
for house_type in house_types:
if house_type in self.RET:
ret.append(self.RET.get(house_type))
data = {'location': ','.join(cities).encode('iso 8859-1'),
'furnished': type == POSTS_TYPES.FURNISHED_RENT,
'areaMin': area_min or '',
'areaMax': area_max or '',
'priceMin': cost_min or '',
'priceMax': cost_max or '',
'transaction': self.TYPES.get(type, 'location'),
'recherche': '',
'mode': '',
'proximity': '0',
'roomMin': nb_rooms or '',
'page': '1'}
query = u'%s%s%s' % (urlencode(data), '&type=', '&type='.join(ret))
return self.search.go(query=query).iter_housings(
query_type=type,
advert_types=advert_types
)
def get_housing(self, _id, housing=None):
return self.housing.go(_id=_id).get_housing(obj=housing)
def get_phone(self, _id):
return self.phone.go(_id=_id).get_phone()
def get_total_page(self, js_datas):
return self.housing.open(js_datas=js_datas).get_total_page()

View File

@ -1,81 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright(C) 2014 Bezleputh
#
# This file is part of a woob module.
#
# This woob module is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This woob module is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this woob module. If not, see <http://www.gnu.org/licenses/>.
from woob.tools.backend import Module
from woob.capabilities.housing import CapHousing, Housing, HousingPhoto
from woob import __version__ as WOOB_VERSION
from .browser import ExplorimmoBrowser
__all__ = ['ExplorimmoModule']
class ExplorimmoModule(Module, CapHousing):
NAME = 'explorimmo'
DESCRIPTION = u'explorimmo website'
MAINTAINER = u'Bezleputh'
EMAIL = 'carton_ben@yahoo.fr'
LICENSE = 'AGPLv3+'
VERSION = WOOB_VERSION
BROWSER = ExplorimmoBrowser
def get_housing(self, housing):
if isinstance(housing, Housing):
id = housing.id
else:
id = housing
housing = None
housing = self.browser.get_housing(id, housing)
return housing
def search_city(self, pattern):
return self.browser.get_cities(pattern)
def search_housings(self, query):
cities = ['%s' % c.id for c in query.cities if c.backend == self.name]
if len(cities) == 0:
return list()
return self.browser.search_housings(query.type, cities, query.nb_rooms,
query.area_min, query.area_max,
query.cost_min, query.cost_max,
query.house_types,
query.advert_types)
def fill_housing(self, housing, fields):
if 'phone' in fields:
housing.phone = self.browser.get_phone(housing.id)
fields.remove('phone')
if len(fields) > 0:
self.browser.get_housing(housing.id, housing)
return housing
def fill_photo(self, photo, fields):
if 'data' in fields and photo.url and not photo.data:
photo.data = self.browser.open(photo.url).content
return photo
OBJECTS = {Housing: fill_housing,
HousingPhoto: fill_photo,
}

View File

@ -1,455 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright(C) 2014 Bezleputh
#
# This file is part of a woob module.
#
# This woob module is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This woob module is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this woob module. If not, see <http://www.gnu.org/licenses/>.
from __future__ import unicode_literals
import json
import math
import re
from decimal import Decimal
from datetime import datetime
from woob.browser.filters.json import Dict
from woob.browser.elements import ItemElement, ListElement, DictElement, method
from woob.browser.pages import JsonPage, HTMLPage, pagination
from woob.browser.filters.standard import (CleanText, CleanDecimal, Currency,
Regexp, Env, BrowserURL, Filter,
Format)
from woob.browser.filters.html import Attr, CleanHTML, XPath
from woob.capabilities.base import NotAvailable, NotLoaded, Currency as BaseCurrency
from woob.capabilities.housing import (Housing, HousingPhoto, City,
UTILITIES, ENERGY_CLASS, POSTS_TYPES,
ADVERT_TYPES, HOUSE_TYPES)
from woob.tools.capabilities.housing.housing import PricePerMeterFilter
from woob.tools.compat import unquote
class CitiesPage(JsonPage):
ENCODING = 'UTF-8'
def build_doc(self, content):
content = super(CitiesPage, self).build_doc(content)
if content:
return content
else:
return [{"locations": []}]
@method
class get_cities(DictElement):
item_xpath = '0/locations'
class item(ItemElement):
klass = City
obj_id = Dict('label')
obj_name = Dict('label')
class SearchPage(HTMLPage):
@pagination
@method
class iter_housings(ListElement):
item_xpath = '//div[starts-with(@id, "bloc-vue-")]'
def next_page(self):
js_datas = CleanText(
'//div[@id="js-data"]/@data-rest-search-request'
)(self).split('?')[-1].split('&')
try:
resultsPerPage = next(
x for x in js_datas if 'resultsPerPage' in x
).split('=')[-1]
currentPageNumber = next(
x for x in js_datas if 'currentPageNumber' in x
).split('=')[-1]
resultCount = CleanText(
'(//div[@id="js-data"]/@data-result-count)[1]'
)(self)
totalPageNumber = math.ceil(
int(resultCount) / int(resultsPerPage)
)
next_page = int(currentPageNumber) + 1
if next_page <= totalPageNumber:
return self.page.url.replace(
'page=%s' % currentPageNumber,
'page=%d' % next_page
)
except StopIteration:
pass
class item(ItemElement):
klass = Housing
price_selector = './/span[@class="price-label"]|./div/div[@class="item-price-pdf"]'
def is_agency(self):
agency = CleanText('.//span[has-class("item-agency-name")]')(self.el)
return 'annonce de particulier' not in agency.lower()
def condition(self):
if len(self.env['advert_types']) == 1:
is_agency = self.is_agency()
if self.env['advert_types'][0] == ADVERT_TYPES.PERSONAL:
return not is_agency
elif self.env['advert_types'][0] == ADVERT_TYPES.PROFESSIONAL:
return is_agency
return Attr('.', 'data-classified-id', default=False)(self)
obj_id = Attr('.', 'data-classified-id')
obj_type = Env('query_type')
obj_title = CleanText('./div/h2[@class="item-type"]')
def obj_advert_type(self):
if self.is_agency():
return ADVERT_TYPES.PROFESSIONAL
else:
return ADVERT_TYPES.PERSONAL
def obj_house_type(self):
type = self.obj_title(self).split()[0].lower()
if type == "appartement" or type == "studio" or type == "chambre":
return HOUSE_TYPES.APART
elif type == "maison" or type == "villa":
return HOUSE_TYPES.HOUSE
elif type == "parking":
return HOUSE_TYPES.PARKING
elif type == "terrain":
return HOUSE_TYPES.LAND
else:
return HOUSE_TYPES.OTHER
def obj_location(self):
script = CleanText('./script')(self)
try:
# Should be standard JSON+LD data
script = json.loads(script)
except ValueError:
try:
# But explorimmo can't write JSON correctly and there
# is a trailing "}"
script = json.loads(script.strip().rstrip('}'))
except ValueError:
script = None
if not script:
return NotLoaded
try:
return '%s (%s)' % (
script['address']['addressLocality'],
script['address']['postalCode']
)
except (KeyError):
return NotLoaded
def obj_cost(self):
cost = CleanDecimal(Regexp(CleanText(self.price_selector, default=''),
r'de (.*) à .*',
default=0))(self)
if cost == 0:
return CleanDecimal(self.price_selector, default=NotAvailable)(self)
else:
return cost
obj_currency = Currency(price_selector)
def obj_utilities(self):
utilities = CleanText(
'./div/div/span[@class="price-label"]|'
'./div/div[@class="item-price-pdf"]|'
'./div/div/span[@class="item-price"]'
)(self)
if "CC" in utilities:
return UTILITIES.INCLUDED
else:
return UTILITIES.UNKNOWN
obj_text = CleanText('./div/p[@itemprop="description"]')
obj_area = CleanDecimal(
Regexp(
obj_title,
r'(.*?)([\d,\.]*) m2(.*?)',
'\\2',
default=None
),
replace_dots=True,
default=NotLoaded
)
obj_url = Format(
"https://immobilier.lefigaro.fr/annonces/annonce-%s.html",
CleanText('./@data-classified-id')
)
obj_price_per_meter = PricePerMeterFilter()
def obj_phone(self):
phone = CleanText('./div/div/ul/li[has-class("js-clickphone")]',
replace=[('Téléphoner : ', '')],
default=NotLoaded)(self)
if '...' in phone:
return NotLoaded
return phone
def obj_details(self):
charges = CleanText('.//span[@class="price-fees"]',
default=None)(self)
if charges:
return {
"fees": charges.split(":")[1].strip()
}
else:
return NotLoaded
def obj_photos(self):
url = CleanText('./div[has-class("default-img")]/img/@data-src')(self)
if url:
url = unquote(url)
if "http://" in url[3:]:
rindex = url.rfind("?")
if rindex == -1:
rindex = None
url = url[url.find("http://", 3):rindex]
return [HousingPhoto(url)]
else:
return NotLoaded
class TypeDecimal(Filter):
def filter(self, el):
return Decimal(el)
class FromTimestamp(Filter):
def filter(self, el):
return datetime.fromtimestamp(el / 1000.0)
class PhonePage(JsonPage):
def get_phone(self):
return self.doc.get('phoneNumber')
class HousingPage2(JsonPage):
@method
class get_housing(ItemElement):
klass = Housing
def is_agency(self):
return Dict('agency/isParticulier')(self) == 'false'
obj_id = Env('_id')
def obj_type(self):
transaction = Dict('characteristics/transaction')(self)
if transaction == 'location':
if Dict('characteristics/isFurnished')(self):
return POSTS_TYPES.FURNISHED_RENT
else:
return POSTS_TYPES.RENT
elif transaction == 'vente':
type = Dict('characteristics/estateType')(self).lower()
if 'viager' in type:
return POSTS_TYPES.VIAGER
else:
return POSTS_TYPES.SALE
else:
return NotAvailable
def obj_advert_type(self):
if self.is_agency:
return ADVERT_TYPES.PROFESSIONAL
else:
return ADVERT_TYPES.PERSONAL
def obj_house_type(self):
type = Dict('characteristics/estateType')(self).lower()
if 'appartement' in type:
return HOUSE_TYPES.APART
elif 'maison' in type:
return HOUSE_TYPES.HOUSE
elif 'parking' in type:
return HOUSE_TYPES.PARKING
elif 'terrain' in type:
return HOUSE_TYPES.LAND
else:
return HOUSE_TYPES.OTHER
obj_title = Dict('characteristics/titleWithTransaction')
obj_location = Format('%s %s %s', Dict('location/address'),
Dict('location/cityLabel'),
Dict('location/postalCode'))
def obj_cost(self):
cost = TypeDecimal(Dict('characteristics/price'))(self)
if cost == 0:
cost = TypeDecimal(Dict('characteristics/priceMin'))(self)
return cost
obj_currency = BaseCurrency.get_currency('')
def obj_utilities(self):
are_fees_included = Dict('characteristics/areFeesIncluded',
default=None)(self)
if are_fees_included:
return UTILITIES.INCLUDED
else:
return UTILITIES.EXCLUDED
obj_text = CleanHTML(Dict('characteristics/description'))
obj_url = BrowserURL('housing_html', _id=Env('_id'))
def obj_area(self):
area = TypeDecimal(Dict('characteristics/area'))(self)
if area == 0:
area = TypeDecimal(Dict('characteristics/areaMin'))(self)
return area
obj_date = FromTimestamp(Dict('characteristics/date'))
obj_bedrooms = TypeDecimal(Dict('characteristics/bedroomCount'))
def obj_rooms(self):
# TODO: Why is roomCount a list?
rooms = Dict('characteristics/roomCount', default=[])(self)
if rooms:
return TypeDecimal(rooms[0])(self)
return NotAvailable
obj_price_per_meter = PricePerMeterFilter()
def obj_photos(self):
photos = []
for img in Dict('characteristics/images')(self):
m = re.search('http://thbr\.figarocms\.net.*(http://.*)', img.get('xl'))
if m:
photos.append(HousingPhoto(m.group(1)))
else:
photos.append(HousingPhoto(img.get('xl')))
return photos
def obj_DPE(self):
DPE = Dict(
'characteristics/energyConsumptionCategory',
default=""
)(self)
return getattr(ENERGY_CLASS, DPE, NotAvailable)
def obj_GES(self):
GES = Dict(
'characteristics/greenhouseGasEmissionCategory',
default=""
)(self)
return getattr(ENERGY_CLASS, GES, NotAvailable)
def obj_details(self):
details = {}
details['fees'] = Dict(
'characteristics/fees', default=NotAvailable
)(self)
details['agencyFees'] = Dict(
'characteristics/agencyFees', default=NotAvailable
)(self)
details['guarantee'] = Dict(
'characteristics/guarantee', default=NotAvailable
)(self)
details['bathrooms'] = Dict(
'characteristics/bathroomCount', default=NotAvailable
)(self)
details['creationDate'] = FromTimestamp(
Dict(
'characteristics/creationDate', default=NotAvailable
),
default=NotAvailable
)(self)
details['availabilityDate'] = Dict(
'characteristics/estateAvailabilityDate', default=NotAvailable
)(self)
details['exposure'] = Dict(
'characteristics/exposure', default=NotAvailable
)(self)
details['heatingType'] = Dict(
'characteristics/heatingType', default=NotAvailable
)(self)
details['floor'] = Dict(
'characteristics/floor', default=NotAvailable
)(self)
details['bedrooms'] = Dict(
'characteristics/bedroomCount', default=NotAvailable
)(self)
details['isFurnished'] = Dict(
'characteristics/isFurnished', default=NotAvailable
)(self)
rooms = Dict('characteristics/roomCount', default=[])(self)
if len(rooms):
details['rooms'] = rooms[0]
details['available'] = Dict(
'characteristics/isAvailable', default=NotAvailable
)(self)
agency = Dict('agency', default=NotAvailable)(self)
details['agency'] = ', '.join([
x for x in [
agency.get('corporateName', ''),
agency.get('corporateAddress', ''),
agency.get('corporatePostalCode', ''),
agency.get('corporateCity', '')
] if x
])
return details
def get_total_page(self):
return self.doc.get('pagination').get('total') if 'pagination' in self.doc else 0
class HousingPage(HTMLPage):
@method
class get_housing(ItemElement):
klass = Housing
obj_id = Env('_id')
obj_title = CleanText('//h1[@itemprop="name"]')
obj_location = CleanText('//span[@class="informations-localisation"]')
obj_cost = CleanDecimal('//span[@itemprop="price"]')
obj_currency = Currency('//span[@itemprop="price"]')
obj_text = CleanHTML('//div[@itemprop="description"]')
obj_url = BrowserURL('housing', _id=Env('_id'))
obj_area = CleanDecimal(Regexp(CleanText('//h1[@itemprop="name"]'),
r'(.*?)(\d*) m2(.*?)', '\\2'), default=NotAvailable)
obj_price_per_meter = PricePerMeterFilter()
def obj_photos(self):
photos = []
for img in XPath('//a[@class="thumbnail-link"]/img[@itemprop="image"]')(self):
url = Regexp(CleanText('./@src'), r'http://thbr\.figarocms\.net.*(http://.*)')(img)
photos.append(HousingPhoto(url))
return photos
def obj_details(self):
details = dict()
for item in XPath('//div[@class="features clearfix"]/ul/li')(self):
key = CleanText('./span[@class="name"]')(item)
value = CleanText('./span[@class="value"]')(item)
if value and key:
details[key] = value
key = CleanText('//div[@class="title-dpe clearfix"]')(self)
value = CleanText('//div[@class="energy-consumption"]')(self)
if value and key:
details[key] = value
return details

View File

@ -1,101 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright(C) 2014 Bezleputh
#
# This file is part of a woob module.
#
# This woob module is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This woob module is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this woob module. If not, see <http://www.gnu.org/licenses/>.
from woob.capabilities.housing import Query, ADVERT_TYPES, POSTS_TYPES
from woob.tools.capabilities.housing.housing_test import HousingTest
from woob.tools.test import BackendTest
class ExplorimmoTest(BackendTest, HousingTest):
MODULE = 'explorimmo'
FIELDS_ALL_HOUSINGS_LIST = [
"id", "type", "advert_type", "house_type", "title", "location",
"utilities", "text", "area", "url"
]
FIELDS_ANY_HOUSINGS_LIST = [
"photos", "cost", "currency"
]
FIELDS_ALL_SINGLE_HOUSING = [
"id", "url", "type", "advert_type", "house_type", "title", "area",
"cost", "currency", "utilities", "date", "location", "text", "rooms",
"details"
]
FIELDS_ANY_SINGLE_HOUSING = [
"bedrooms",
"photos",
"DPE",
"GES",
"phone"
]
def test_explorimmo_rent(self):
query = Query()
query.area_min = 20
query.cost_max = 1500
query.type = POSTS_TYPES.RENT
query.cities = []
for city in self.backend.search_city('paris'):
city.backend = self.backend.name
query.cities.append(city)
self.check_against_query(query)
def test_explorimmo_sale(self):
query = Query()
query.area_min = 20
query.type = POSTS_TYPES.SALE
query.cities = []
for city in self.backend.search_city('paris'):
city.backend = self.backend.name
query.cities.append(city)
self.check_against_query(query)
def test_explorimmo_furnished_rent(self):
query = Query()
query.area_min = 20
query.cost_max = 1500
query.type = POSTS_TYPES.FURNISHED_RENT
query.cities = []
for city in self.backend.search_city('paris'):
city.backend = self.backend.name
query.cities.append(city)
self.check_against_query(query)
def test_explorimmo_viager(self):
query = Query()
query.type = POSTS_TYPES.VIAGER
query.cities = []
for city in self.backend.search_city('85'):
city.backend = self.backend.name
query.cities.append(city)
self.check_against_query(query)
def test_explorimmo_personal(self):
query = Query()
query.area_min = 20
query.cost_max = 900
query.type = POSTS_TYPES.RENT
query.advert_types = [ADVERT_TYPES.PERSONAL]
query.cities = []
for city in self.backend.search_city('paris'):
city.backend = self.backend.name
query.cities.append(city)
results = list(self.backend.search_housings(query))
self.assertEqual(len(results), 0)

View File

@ -1,26 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright(C) 2017 Phyks (Lucas Verney)
#
# This file is part of a woob module.
#
# This woob module is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This woob module is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this woob module. If not, see <http://www.gnu.org/licenses/>.
from __future__ import unicode_literals
from .module import FonciaModule
__all__ = ['FonciaModule']

View File

@ -1,61 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright(C) 2017 Phyks (Lucas Verney)
#
# This file is part of a woob module.
#
# This woob module is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This woob module is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this woob module. If not, see <http://www.gnu.org/licenses/>.
from __future__ import unicode_literals
from woob.browser import PagesBrowser, URL
from .constants import QUERY_TYPES
from .pages import CitiesPage, HousingPage, SearchPage, SearchResultsPage
class FonciaBrowser(PagesBrowser):
BASEURL = 'https://fr.foncia.com'
cities = URL(r'/recherche/autocomplete\?term=(?P<term>.+)', CitiesPage)
housing = URL(r'/(?P<type>[^/]+)/.*\d+.htm', HousingPage)
search_results = URL(r'/(?P<type>[^/]+)/.*', SearchResultsPage)
search = URL(r'/(?P<type>.+)', SearchPage)
def get_cities(self, pattern):
"""
Get cities matching a given pattern.
"""
return self.cities.open(term=pattern).iter_cities()
def search_housings(self, query, cities):
"""
Search for housings matching given query.
"""
try:
query_type = QUERY_TYPES[query.type]
except KeyError:
return []
self.search.go(type=query_type).do_search(query, cities)
return self.page.iter_housings(query_type=query.type)
def get_housing(self, housing):
"""
Get specific housing.
"""
query_type, housing = housing.split(':')
self.search.go(type=query_type).find_housing(query_type, housing)
return self.page.get_housing()

View File

@ -1,24 +0,0 @@
from woob.capabilities.housing import POSTS_TYPES, HOUSE_TYPES
QUERY_TYPES = {
POSTS_TYPES.RENT: 'location',
POSTS_TYPES.SALE: 'achat',
POSTS_TYPES.FURNISHED_RENT: 'location'
}
QUERY_HOUSE_TYPES = {
HOUSE_TYPES.APART: ['appartement', 'appartement-meuble'],
HOUSE_TYPES.HOUSE: ['maison'],
HOUSE_TYPES.PARKING: ['parking'],
HOUSE_TYPES.LAND: ['terrain'],
HOUSE_TYPES.OTHER: ['chambre', 'programme-neuf',
'local-commercial', 'immeuble']
}
AVAILABLE_TYPES = {
POSTS_TYPES.RENT: ['appartement', 'maison', 'parking', 'chambre',
'local-commercial'],
POSTS_TYPES.SALE: ['appartement', 'maison', 'parking', 'local-commercial',
'terrain', 'immeuble', 'programme-neuf'],
POSTS_TYPES.FURNISHED_RENT: ['appartement-meuble']
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -1,75 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright(C) 2017 Phyks (Lucas Verney)
#
# This file is part of a woob module.
#
# This woob module is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This woob module is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this woob module. If not, see <http://www.gnu.org/licenses/>.
from __future__ import unicode_literals
from woob.tools.backend import Module
from woob.capabilities.housing import CapHousing, Housing, ADVERT_TYPES, HousingPhoto
from woob import __version__ as WOOB_VERSION
from .browser import FonciaBrowser
__all__ = ['FonciaModule']
class FonciaModule(Module, CapHousing):
NAME = 'foncia'
DESCRIPTION = u'Foncia housing website.'
MAINTAINER = u'Phyks (Lucas Verney)'
EMAIL = 'phyks@phyks.me'
LICENSE = 'AGPLv3+'
VERSION = WOOB_VERSION
BROWSER = FonciaBrowser
def get_housing(self, housing):
return self.browser.get_housing(housing)
def search_city(self, pattern):
return self.browser.get_cities(pattern)
def search_housings(self, query):
if (
len(query.advert_types) == 1 and
query.advert_types[0] == ADVERT_TYPES.PERSONAL
):
# Foncia is pro only
return list()
cities = ','.join(
['%s' % c.name for c in query.cities if c.backend == self.name]
)
if len(cities) == 0:
return []
return self.browser.search_housings(query, cities)
def fill_housing(self, housing, fields):
if len(fields) > 0:
self.browser.get_housing(housing)
return housing
def fill_photo(self, photo, fields):
if 'data' in fields and photo.url and not photo.data:
photo.data = self.browser.open(photo.url).content
return photo
OBJECTS = {Housing: fill_housing, HousingPhoto: fill_photo}

View File

@ -1,359 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright(C) 2017 Phyks (Lucas Verney)
#
# This file is part of a woob module.
#
# This woob module is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This woob module is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this woob module. If not, see <http://www.gnu.org/licenses/>.
from __future__ import unicode_literals
import datetime
from woob.browser.pages import JsonPage, HTMLPage, pagination
from woob.browser.filters.standard import (
CleanDecimal, CleanText, Currency, Date, Env, Format, Regexp, RegexpError
)
from woob.browser.filters.html import AbsoluteLink, Attr, Link, XPathNotFound
from woob.browser.elements import ItemElement, ListElement, method
from woob.capabilities.base import NotAvailable, NotLoaded
from woob.capabilities.housing import (
City, Housing, HousingPhoto,
UTILITIES, ENERGY_CLASS, POSTS_TYPES, ADVERT_TYPES
)
from woob.tools.capabilities.housing.housing import PricePerMeterFilter
from .constants import AVAILABLE_TYPES, QUERY_TYPES, QUERY_HOUSE_TYPES
class CitiesPage(JsonPage):
def iter_cities(self):
cities_list = self.doc
if isinstance(self.doc, dict):
cities_list = self.doc.values()
for city in cities_list:
city_obj = City()
city_obj.id = city
city_obj.name = city
yield city_obj
class HousingPage(HTMLPage):
@method
class get_housing(ItemElement):
klass = Housing
obj_id = Format(
'%s:%s',
Env('type'),
Attr('//div[boolean(@data-property-reference)]', 'data-property-reference')
)
obj_advert_type = ADVERT_TYPES.PROFESSIONAL
def obj_type(self):
type = Env('type')(self)
if type == 'location':
if 'appartement-meuble' in self.page.url:
return POSTS_TYPES.FURNISHED_RENT
else:
return POSTS_TYPES.RENT
elif type == 'achat':
return POSTS_TYPES.SALE
else:
return NotAvailable
def obj_url(self):
return self.page.url
def obj_house_type(self):
url = self.obj_url()
for house_type, types in QUERY_HOUSE_TYPES.items():
for type in types:
if ('/%s/' % type) in url:
return house_type
return NotAvailable
obj_title = CleanText('//h1[has-class("OfferTop-title")]')
obj_area = CleanDecimal(
Regexp(
CleanText(
'//div[has-class("MiniData")]//p[has-class("MiniData-item")][1]'
),
r'(\d*\.*\d*) .*',
default=NotAvailable
),
default=NotAvailable
)
obj_cost = CleanDecimal(
'//span[has-class("OfferTop-price")]',
default=NotAvailable
)
obj_price_per_meter = PricePerMeterFilter()
obj_currency = Currency(
'//span[has-class("OfferTop-price")]'
)
obj_location = Format(
'%s - %s',
CleanText('//p[@data-behat="adresseBien"]'),
CleanText('//p[has-class("OfferTop-loc")]')
)
obj_text = CleanText('//div[has-class("OfferDetails-content")]/p[1]')
obj_phone = Regexp(
Link(
'//a[has-class("OfferContact-btn--tel")]'
),
r'tel:(.*)'
)
def obj_photos(self):
photos = []
for photo in self.xpath('//div[has-class("OfferSlider")]//img'):
photo_url = Attr('.', 'src')(photo)
photo_url = photo_url.replace('640/480', '800/600')
photos.append(HousingPhoto(photo_url))
return photos
obj_date = datetime.date.today()
def obj_utilities(self):
price = CleanText(
'//p[has-class("OfferTop-price")]'
)(self)
if "charges comprises" in price.lower():
return UTILITIES.INCLUDED
else:
return UTILITIES.EXCLUDED
obj_rooms = CleanDecimal(
'//div[has-class("MiniData")]//p[has-class("MiniData-item")][2]',
default=NotAvailable
)
obj_bedrooms = CleanDecimal(
'//div[has-class("MiniData")]//p[has-class("MiniData-item")][3]',
default=NotAvailable
)
def obj_DPE(self):
try:
electric_consumption = CleanDecimal(Regexp(
Attr('//div[has-class("OfferDetails-content")]//img', 'src'),
r'https://dpe.foncia.net\/(\d+)\/.*'
))(self)
except (RegexpError, XPathNotFound):
electric_consumption = None
DPE = ""
if electric_consumption is not None:
if electric_consumption <= 50:
DPE = "A"
elif 50 < electric_consumption <= 90:
DPE = "B"
elif 90 < electric_consumption <= 150:
DPE = "C"
elif 150 < electric_consumption <= 230:
DPE = "D"
elif 230 < electric_consumption <= 330:
DPE = "E"
elif 330 < electric_consumption <= 450:
DPE = "F"
else:
DPE = "G"
return getattr(ENERGY_CLASS, DPE, NotAvailable)
return NotAvailable
def obj_details(self):
details = {}
dispo = Date(
Regexp(
CleanText('//p[has-class("OfferTop-dispo")]'),
r'.* (\d\d\/\d\d\/\d\d\d\d)',
default=datetime.date.today().isoformat()
)
)(self)
if dispo is not None:
details["dispo"] = dispo
priceMentions = CleanText(
'//p[has-class("OfferTop-mentions")]',
default=None
)(self)
if priceMentions is not None:
details["priceMentions"] = priceMentions
agency = CleanText(
'//p[has-class("OfferContact-address")]',
default=None
)(self)
if agency is not None:
details["agency"] = agency
for item in self.xpath('//div[has-class("OfferDetails-columnize")]/div'):
category = CleanText(
'./h3[has-class("OfferDetails-title--2")]',
default=None
)(item)
if not category:
continue
details[category] = {}
for detail_item in item.xpath('.//ul[has-class("List--data")]/li'):
detail_title = CleanText('.//span[has-class("List-data")]')(detail_item)
detail_value = CleanText('.//*[has-class("List-value")]')(detail_item)
details[category][detail_title] = detail_value
for detail_item in item.xpath('.//ul[has-class("List--bullet")]/li'):
detail_title = CleanText('.')(detail_item)
details[category][detail_title] = True
try:
electric_consumption = CleanDecimal(Regexp(
Attr('//div[has-class("OfferDetails-content")]//img', 'src'),
r'https://dpe.foncia.net\/(\d+)\/.*'
))(self)
details["electric_consumption"] = (
'{} kWhEP/m².an'.format(electric_consumption)
)
except (RegexpError, XPathNotFound):
pass
return details
class SearchPage(HTMLPage):
def do_search(self, query, cities):
form = self.get_form('//form[@name="searchForm"]')
form['searchForm[type]'] = QUERY_TYPES.get(query.type, None)
form['searchForm[localisation]'] = cities
form['searchForm[type_bien][]'] = []
for house_type in query.house_types:
try:
form['searchForm[type_bien][]'].extend(
QUERY_HOUSE_TYPES[house_type]
)
except KeyError:
pass
form['searchForm[type_bien][]'] = [
x for x in form['searchForm[type_bien][]']
if x in AVAILABLE_TYPES.get(query.type, [])
]
if query.area_min:
form['searchForm[surface_min]'] = query.area_min
if query.area_max:
form['searchForm[surface_max]'] = query.area_max
if query.cost_min:
form['searchForm[prix_min]'] = query.cost_min
if query.cost_max:
form['searchForm[prix_max]'] = query.cost_max
if query.nb_rooms:
form['searchForm[pieces]'] = [i for i in range(1, query.nb_rooms + 1)]
form.submit()
def find_housing(self, query_type, housing):
form = self.get_form('//form[@name="searchForm"]')
form['searchForm[type]'] = query_type
form['searchForm[reference]'] = housing
form.submit()
class SearchResultsPage(HTMLPage):
@pagination
@method
class iter_housings(ListElement):
item_xpath = '//article[has-class("TeaserOffer")]'
next_page = Link('//div[has-class("Pagination--more")]/a[contains(text(), "Suivant")]')
class item(ItemElement):
klass = Housing
obj_id = Format(
'%s:%s',
Env('type'),
Attr('.//span[boolean(@data-reference)]', 'data-reference')
)
obj_url = AbsoluteLink('.//h3[has-class("TeaserOffer-title")]/a')
obj_type = Env('query_type')
obj_advert_type = ADVERT_TYPES.PROFESSIONAL
def obj_house_type(self):
url = self.obj_url(self)
for house_type, types in QUERY_HOUSE_TYPES.items():
for type in types:
if ('/%s/' % type) in url:
return house_type
return NotLoaded
obj_url = AbsoluteLink('.//h3[has-class("TeaserOffer-title")]/a')
obj_title = CleanText('.//h3[has-class("TeaserOffer-title")]')
obj_area = CleanDecimal(
Regexp(
CleanText(
'.//div[has-class("MiniData")]//p[@data-behat="surfaceDesBiens"]'
),
r'(\d*\.*\d*) .*',
default=NotAvailable
),
default=NotAvailable
)
obj_cost = CleanDecimal(
'.//strong[has-class("TeaserOffer-price-num")]',
default=NotAvailable
)
obj_price_per_meter = PricePerMeterFilter()
obj_currency = Currency(
'.//strong[has-class("TeaserOffer-price-num")]'
)
obj_location = CleanText('.//p[has-class("TeaserOffer-loc")]')
obj_text = CleanText('.//p[has-class("TeaserOffer-description")]')
def obj_photos(self):
url = CleanText(Attr('.//a[has-class("TeaserOffer-ill")]/img', 'src'))(self)
# If the used photo is a default no photo, the src is on the same domain.
if url[0] == '/':
return []
else:
return [HousingPhoto(url)]
obj_date = datetime.date.today()
def obj_utilities(self):
price = CleanText(
'.//strong[has-class("TeaserOffer-price-num")]'
)(self)
if "charges comprises" in price.lower():
return UTILITIES.INCLUDED
else:
return UTILITIES.EXCLUDED
obj_rooms = CleanDecimal(
'.//div[has-class("MiniData")]//p[@data-behat="nbPiecesDesBiens"]',
default=NotLoaded
)
obj_bedrooms = CleanDecimal(
'.//div[has-class("MiniData")]//p[@data-behat="nbChambresDesBiens"]',
default=NotLoaded
)
def obj_details(self):
return {
"dispo": Date(
Attr('.//span[boolean(@data-dispo)]', 'data-dispo',
default=datetime.date.today().isoformat())
)(self),
"priceMentions": CleanText('.//span[has-class("TeaserOffer-price-mentions")]')(self)
}

View File

@ -1,95 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright(C) 2017 Phyks (Lucas Verney)
#
# This file is part of a woob module.
#
# This woob module is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This woob module is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this woob module. If not, see <http://www.gnu.org/licenses/>.
from __future__ import unicode_literals
from woob.capabilities.housing import (
Query, POSTS_TYPES, ADVERT_TYPES
)
from woob.tools.capabilities.housing.housing_test import HousingTest
from woob.tools.test import BackendTest
class FonciaTest(BackendTest, HousingTest):
MODULE = 'foncia'
FIELDS_ALL_HOUSINGS_LIST = [
"id", "type", "advert_type", "house_type", "url", "title", "area",
"cost", "currency", "date", "location", "text", "details"
]
FIELDS_ANY_HOUSINGS_LIST = [
"photos",
"rooms"
]
FIELDS_ALL_SINGLE_HOUSING = [
"id", "url", "type", "advert_type", "house_type", "title", "area",
"cost", "currency", "utilities", "date", "location", "text", "phone",
"DPE", "details"
]
FIELDS_ANY_SINGLE_HOUSING = [
"bedrooms",
"photos",
"rooms"
]
def test_foncia_rent(self):
query = Query()
query.area_min = 20
query.cost_max = 1500
query.type = POSTS_TYPES.RENT
query.cities = []
for city in self.backend.search_city('paris'):
city.backend = self.backend.name
query.cities.append(city)
self.check_against_query(query)
def test_foncia_sale(self):
query = Query()
query.area_min = 20
query.type = POSTS_TYPES.SALE
query.cities = []
for city in self.backend.search_city('paris'):
city.backend = self.backend.name
query.cities.append(city)
self.check_against_query(query)
def test_foncia_furnished_rent(self):
query = Query()
query.area_min = 20
query.cost_max = 1500
query.type = POSTS_TYPES.FURNISHED_RENT
query.cities = []
for city in self.backend.search_city('paris'):
city.backend = self.backend.name
query.cities.append(city)
self.check_against_query(query)
def test_foncia_personal(self):
query = Query()
query.area_min = 20
query.cost_max = 900
query.type = POSTS_TYPES.RENT
query.advert_types = [ADVERT_TYPES.PERSONAL]
query.cities = []
for city in self.backend.search_city('paris'):
city.backend = self.backend.name
query.cities.append(city)
results = list(self.backend.search_housings(query))
self.assertEqual(len(results), 0)

View File

@ -1,24 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright(C) 2014 Bezleputh
#
# This file is part of a woob module.
#
# This woob module is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This woob module is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this woob module. If not, see <http://www.gnu.org/licenses/>.
from .module import LeboncoinModule
__all__ = ['LeboncoinModule']

View File

@ -1,145 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright(C) 2014 Bezleputh
#
# This file is part of a woob module.
#
# This woob module is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This woob module is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this woob module. If not, see <http://www.gnu.org/licenses/>.
from woob.tools.json import json
from woob.browser import PagesBrowser, URL
from woob.capabilities.housing import (TypeNotSupported, POSTS_TYPES,
HOUSE_TYPES, ADVERT_TYPES)
from .pages import CityListPage, HousingListPage, HousingPage, PhonePage, HomePage
class LeboncoinBrowser(PagesBrowser):
BASEURL = 'https://www.leboncoin.fr/'
city = URL('ajax/location_list.html\?city=(?P<city>.*)&zipcode=(?P<zip>.*)', CityListPage)
housing = URL('ventes_immobilieres/(?P<_id>.*).htm', HousingPage)
home = URL('annonces/offres', HomePage)
api = URL('https://api.leboncoin.fr/finder/search', HousingListPage)
phone = URL('https://api.leboncoin.fr/api/utils/phonenumber.json', PhonePage)
TYPES = {POSTS_TYPES.RENT: '10',
POSTS_TYPES.FURNISHED_RENT: '10',
POSTS_TYPES.SALE: '9',
POSTS_TYPES.SHARING: '11', }
RET = {HOUSE_TYPES.HOUSE: '1',
HOUSE_TYPES.APART: '2',
HOUSE_TYPES.LAND: '3',
HOUSE_TYPES.PARKING: '4',
HOUSE_TYPES.OTHER: '5'}
def __init__(self, *args, **kwargs):
super(LeboncoinBrowser, self).__init__(*args, **kwargs)
def get_cities(self, pattern):
city = ''
zip_code = ''
if pattern.isdigit():
zip_code = pattern
else:
city = pattern.replace(" ", "_")
return self.city.go(city=city, zip=zip_code).get_cities()
def search_housings(self, query, module_name):
if query.type not in self.TYPES.keys():
return TypeNotSupported()
data = {}
data['filters'] = {}
data['filters']['category'] = {}
data['filters']['category']['id'] = self.TYPES.get(query.type)
data['filters']['enums'] = {}
data['filters']['enums']['ad_type'] = ['offer']
data['filters']['enums']['real_estate_type'] = []
for t in query.house_types:
t = self.RET.get(t)
if t:
data['filters']['enums']['real_estate_type'].append(t)
if query.type == POSTS_TYPES.FURNISHED_RENT:
data['filters']['enums']['furnished'] = ['1']
elif query.type == POSTS_TYPES.RENT:
data['filters']['enums']['furnished'] = ['2']
data['filters']['keywords'] = {}
data['filters']['ranges'] = {}
if query.cost_max or query.cost_min:
data['filters']['ranges']['price'] = {}
if query.cost_max:
data['filters']['ranges']['price']['max'] = query.cost_max
if query.cost_min:
data['filters']['ranges']['price']['min'] = query.cost_min
if query.area_max or query.area_min:
data['filters']['ranges']['square'] = {}
if query.area_max:
data['filters']['ranges']['square']['max'] = query.area_max
if query.area_min:
data['filters']['ranges']['square']['min'] = query.area_min
if query.nb_rooms:
data['filters']['ranges']['rooms'] = {}
data['filters']['ranges']['rooms']['min'] = query.nb_rooms
data['filters']['location'] = {}
data['filters']['location']['city_zipcodes'] = []
for c in query.cities:
if c.backend == module_name:
_c = c.id.split(' ')
__c = {}
__c['city'] = _c[0]
__c['zipcode'] = _c[1]
__c['label'] = c.name
data['filters']['location']['city_zipcodes'].append(__c)
if len(query.advert_types) == 1:
if query.advert_types[0] == ADVERT_TYPES.PERSONAL:
data['owner_type'] = 'private'
elif query.advert_types[0] == ADVERT_TYPES.PROFESSIONAL:
data['owner_type'] = 'pro'
else:
data['owner_type'] = 'all'
data['limit'] = 100
data['limit_alu'] = 3
data['offset'] = 0
self.session.headers.update({"api_key": self.home.go().get_api_key()})
return self.api.go(data=json.dumps(data)).get_housing_list(query_type=query.type, data=data)
def get_housing(self, _id, obj=None):
return self.housing.go(_id=_id).get_housing(obj=obj)
def get_phone(self, _id):
api_key = self.housing.stay_or_go(_id=_id).get_api_key()
data = {'list_id': _id,
'app_id': 'leboncoin_web_utils',
'key': api_key,
'text': 1, }
return self.phone.go(data=data).get_phone()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 766 B

View File

@ -1,67 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright(C) 2014 Bezleputh
#
# This file is part of a woob module.
#
# This woob module is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This woob module is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this woob module. If not, see <http://www.gnu.org/licenses/>.
from woob.tools.backend import Module
from woob.capabilities.housing import (CapHousing, Housing, HousingPhoto)
from .browser import LeboncoinBrowser
from woob import __version__ as WOOB_VERSION
__all__ = ['LeboncoinModule']
class LeboncoinModule(Module, CapHousing):
NAME = 'leboncoin'
DESCRIPTION = u'search house on leboncoin website'
MAINTAINER = u'Bezleputh'
EMAIL = 'carton_ben@yahoo.fr'
LICENSE = 'AGPLv3+'
VERSION = WOOB_VERSION
BROWSER = LeboncoinBrowser
def create_default_browser(self):
return self.create_browser()
def get_housing(self, _id):
return self.browser.get_housing(_id)
def fill_housing(self, housing, fields):
if 'phone' in fields:
housing.phone = self.browser.get_phone(housing.id)
fields.remove('phone')
if len(fields) > 0:
self.browser.get_housing(housing.id, housing)
return housing
def fill_photo(self, photo, fields):
if 'data' in fields and photo.url and not photo.data:
photo.data = self.browser.open(photo.url).content
return photo
def search_city(self, pattern):
return self.browser.get_cities(pattern)
def search_housings(self, query):
return self.browser.search_housings(query, self.name)
OBJECTS = {Housing: fill_housing, HousingPhoto: fill_photo}

View File

@ -1,301 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright(C) 2014 Bezleputh
#
# This file is part of a woob module.
#
# This woob module is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This woob module is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this woob module. If not, see <http://www.gnu.org/licenses/>.
from __future__ import unicode_literals
import requests
from woob.browser.pages import HTMLPage, JsonPage, pagination
from woob.browser.elements import ItemElement, ListElement, method, DictElement
from woob.capabilities.base import Currency as BaseCurrency
from woob.browser.filters.standard import (CleanText, CleanDecimal, _Filter,
Env, DateTime, Format)
from woob.browser.filters.json import Dict
from woob.capabilities.housing import (City, Housing, HousingPhoto,
UTILITIES, ENERGY_CLASS, POSTS_TYPES,
ADVERT_TYPES, HOUSE_TYPES)
from woob.capabilities.base import NotAvailable
from woob.tools.capabilities.housing.housing import PricePerMeterFilter
from decimal import Decimal
from lxml import etree
import json
class PopDetail(_Filter):
def __init__(self, name, default=NotAvailable):
super(PopDetail, self).__init__(default)
self.name = name
def __call__(self, item):
return item.env['details'].pop(self.name, self.default)
class CityListPage(HTMLPage):
def build_doc(self, content):
content = super(CityListPage, self).build_doc(content)
if content.getroot() is not None:
return content
return etree.Element("html")
@method
class get_cities(ListElement):
item_xpath = '//li'
class item(ItemElement):
klass = City
obj_id = Format('%s %s',
CleanText('./span[has-class("city")]'),
CleanText('./span[@class="zipcode"]'))
obj_name = Format('%s %s',
CleanText('./span[has-class("city")]'),
CleanText('./span[@class="zipcode"]'))
class HomePage(HTMLPage):
def __init__(self, *args, **kwargs):
HTMLPage.__init__(self, *args, **kwargs)
add_content = CleanText('(//body/script)[4]', replace=[('window.FLUX_STATE = ', '')])(self.doc) or '{}'
api_content = CleanText('(//body/script[@id="__NEXT_DATA__"])')(self.doc)
self.htmldoc = self.doc
self.api_content = json.loads(api_content)
self.doc = json.loads(add_content)
def get_api_key(self):
return Dict('runtimeConfig/API/KEY')(self.api_content)
class HousingListPage(JsonPage):
def __init__(self, *args, **kwargs):
JsonPage.__init__(self, *args, **kwargs)
if 'ads' not in self.doc:
self.doc['ads'] = []
@pagination
@method
class get_housing_list(DictElement):
item_xpath = 'ads'
def next_page(self):
data = Env('data')(self)
if data['offset'] > self.page.doc['total_all']:
return
data['offset'] = data['offset'] + data['limit']
return requests.Request("POST", self.page.url, data=json.dumps(data))
class item(ItemElement):
klass = Housing
def parse(self, el):
self.env['details'] = {obj['key']: obj['value_label'] for obj in self.el['attributes']}
obj_id = Dict('list_id')
obj_url = Dict('url')
obj_type = Env('query_type')
obj_area = CleanDecimal(PopDetail('square',
default=0),
default=NotAvailable)
obj_rooms = CleanDecimal(PopDetail('rooms',
default=0),
default=NotAvailable)
def obj_GES(self):
ges = CleanText(PopDetail('ges', default='|'))(self)
return getattr(ENERGY_CLASS, ges[0], NotAvailable)
def obj_DPE(self):
dpe = CleanText(PopDetail('energy_rate', default='|'))(self)
return getattr(ENERGY_CLASS, dpe[0], NotAvailable)
def obj_house_type(self):
value = CleanText(PopDetail('real_estate_type'), default=' ')(self).lower()
if value == 'parking':
return HOUSE_TYPES.PARKING
elif value == 'appartement':
return HOUSE_TYPES.APART
elif value == 'maison':
return HOUSE_TYPES.HOUSE
elif value == 'terrain':
return HOUSE_TYPES.LAND
else:
return HOUSE_TYPES.OTHER
def obj_utilities(self):
value = CleanText(PopDetail('charges_included',
default='Non'),
default=NotAvailable)(self)
if value == "Oui":
return UTILITIES.INCLUDED
else:
return UTILITIES.EXCLUDED
def obj_advert_type(self):
line_pro = Dict('owner/type')(self)
if line_pro == u'pro':
return ADVERT_TYPES.PROFESSIONAL
else:
return ADVERT_TYPES.PERSONAL
obj_title = Dict('subject')
obj_cost = CleanDecimal(Dict('price/0', default=NotAvailable), default=Decimal(0))
obj_currency = BaseCurrency.get_currency(u'')
obj_text = Dict('body')
obj_location = Dict('location/city_label')
obj_date = DateTime(Dict('first_publication_date'))
def obj_photos(self):
photos = []
for img in Dict('images/urls_large', default=[])(self):
photos.append(HousingPhoto(img))
return photos
def obj_type(self):
try:
breadcrumb = int(Dict('category_id')(self))
except ValueError:
breadcrumb = None
if breadcrumb == 11:
return POSTS_TYPES.SHARING
elif breadcrumb == 10:
isFurnished = CleanText(PopDetail('furnished', default=' '))(self)
if isFurnished.lower() == u'meublé':
return POSTS_TYPES.FURNISHED_RENT
else:
return POSTS_TYPES.RENT
else:
return POSTS_TYPES.SALE
obj_price_per_meter = PricePerMeterFilter()
obj_details = Env('details')
class HousingPage(HomePage):
def __init__(self, *args, **kwargs):
HomePage.__init__(self, *args, **kwargs)
self.doc = self.api_content["props"]["pageProps"]["ad"]
def get_api_key(self):
return Dict('runtimeConfig/API/KEY_JSON')(self.api_content)
@method
class get_housing(ItemElement):
klass = Housing
def parse(self, el):
self.env['details'] = {obj['key']: obj['value_label'] for obj in el['attributes']}
obj_id = Env('_id')
obj_area = CleanDecimal(PopDetail('square',
default=0),
default=NotAvailable)
obj_rooms = CleanDecimal(PopDetail('rooms',
default=0),
default=NotAvailable)
def obj_GES(self):
ges = CleanText(PopDetail('ges', default='|'))(self)
return getattr(ENERGY_CLASS, ges[0], NotAvailable)
def obj_DPE(self):
dpe = CleanText(PopDetail('energy_rate', default='|'))(self)
return getattr(ENERGY_CLASS, dpe[0], NotAvailable)
def obj_house_type(self):
value = CleanText(PopDetail('real_estate_type'), default=' ')(self).lower()
if value == 'parking':
return HOUSE_TYPES.PARKING
elif value == 'appartement':
return HOUSE_TYPES.APART
elif value == 'maison':
return HOUSE_TYPES.HOUSE
elif value == 'terrain':
return HOUSE_TYPES.LAND
else:
return HOUSE_TYPES.OTHER
def obj_utilities(self):
value = CleanText(PopDetail('charges_included',
default='Non'),
default=NotAvailable)(self)
if value == "Oui":
return UTILITIES.INCLUDED
else:
return UTILITIES.EXCLUDED
obj_title = Dict('subject')
obj_cost = CleanDecimal(Dict('price/0', default=NotAvailable), default=Decimal(0))
obj_currency = BaseCurrency.get_currency(u'')
obj_text = Dict('body')
obj_location = Dict('location/city_label')
def obj_advert_type(self):
line_pro = Dict('owner/type')(self)
if line_pro == u'pro':
return ADVERT_TYPES.PROFESSIONAL
else:
return ADVERT_TYPES.PERSONAL
obj_date = DateTime(Dict('first_publication_date'))
def obj_photos(self):
photos = []
for img in Dict('images/urls_large', default=[])(self):
photos.append(HousingPhoto(img))
return photos
def obj_type(self):
try:
breadcrumb = int(Dict('category_id')(self))
except ValueError:
breadcrumb = None
if breadcrumb == 11:
return POSTS_TYPES.SHARING
elif breadcrumb == 10:
isFurnished = CleanText(PopDetail('furnished', default=' '))(self)
if isFurnished.lower() == u'meublé':
return POSTS_TYPES.FURNISHED_RENT
else:
return POSTS_TYPES.RENT
else:
return POSTS_TYPES.SALE
obj_price_per_meter = PricePerMeterFilter()
obj_url = Dict('url')
obj_details = Env('details')
class PhonePage(JsonPage):
def get_phone(self):
if Dict('utils/status')(self.doc) == u'OK':
return Dict('utils/phonenumber')(self.doc)
return NotAvailable

View File

@ -1,105 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright(C) 2014 Bezleputh
#
# This file is part of a woob module.
#
# This woob module is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This woob module is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this woob module. If not, see <http://www.gnu.org/licenses/>.
from woob.tools.test import BackendTest
from woob.tools.value import Value
from woob.capabilities.housing import Query, POSTS_TYPES, ADVERT_TYPES
from woob.tools.capabilities.housing.housing_test import HousingTest
class LeboncoinTest(BackendTest, HousingTest):
MODULE = 'leboncoin'
FIELDS_ALL_HOUSINGS_LIST = [
"id", "type", "advert_type", "url", "title",
"currency", "utilities", "date", "location", "text"
]
FIELDS_ANY_HOUSINGS_LIST = [
"area",
"cost",
"price_per_meter",
"photos"
]
FIELDS_ALL_SINGLE_HOUSING = [
"id", "url", "type", "advert_type", "house_type", "title",
"cost", "currency", "utilities", "date", "location", "text",
"rooms", "details"
]
FIELDS_ANY_SINGLE_HOUSING = [
"area",
"GES",
"DPE",
"photos",
# Don't test phone as leboncoin API is strongly rate-limited
]
def setUp(self):
if not self.is_backend_configured():
self.backend.config['advert_type'] = Value(value='a')
self.backend.config['region'] = Value(value='ile_de_france')
def test_leboncoin_rent(self):
query = Query()
query.area_min = 20
query.cost_max = 1500
query.type = POSTS_TYPES.RENT
query.cities = []
for city in self.backend.search_city('paris'):
city.backend = self.backend.name
query.cities.append(city)
if len(query.cities) == 3:
break
self.check_against_query(query)
def test_leboncoin_sale(self):
query = Query()
query.area_min = 20
query.type = POSTS_TYPES.SALE
query.cities = []
for city in self.backend.search_city('paris'):
city.backend = self.backend.name
query.cities.append(city)
if len(query.cities) == 3:
break
self.check_against_query(query)
def test_leboncoin_furnished_rent(self):
query = Query()
query.area_min = 20
query.cost_max = 1500
query.type = POSTS_TYPES.FURNISHED_RENT
query.cities = []
for city in self.backend.search_city('paris'):
city.backend = self.backend.name
query.cities.append(city)
if len(query.cities) == 3:
break
self.check_against_query(query)
def test_leboncoin_professional(self):
query = Query()
query.area_min = 20
query.cost_max = 900
query.type = POSTS_TYPES.RENT
query.advert_types = [ADVERT_TYPES.PROFESSIONAL]
query.cities = []
for city in self.backend.search_city('paris'):
city.backend = self.backend.name
query.cities.append(city)
self.check_against_query(query)

View File

@ -1,24 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright(C) 2014 Bezleputh
#
# This file is part of a woob module.
#
# This woob module is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This woob module is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this woob module. If not, see <http://www.gnu.org/licenses/>.
from .module import LogicimmoModule
__all__ = ['LogicimmoModule']

View File

@ -1,108 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright(C) 2014 Bezleputh
#
# This file is part of a woob module.
#
# This woob module is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This woob module is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this woob module. If not, see <http://www.gnu.org/licenses/>.
from woob.browser import PagesBrowser, URL
from woob.browser.profiles import Firefox
from woob.capabilities.housing import (TypeNotSupported, POSTS_TYPES,
HOUSE_TYPES)
from .pages import CitiesPage, SearchPage, HousingPage, PhonePage
class LogicimmoBrowser(PagesBrowser):
BASEURL = 'https://www.logic-immo.com/'
PROFILE = Firefox()
city = URL('asset/t9/getLocalityT9.php\?site=fr&lang=fr&json=%22(?P<pattern>.*)%22',
CitiesPage)
search = URL('(?P<type>location-immobilier|vente-immobilier|recherche-colocation)-(?P<cities>.*)/options/(?P<options>.*)', SearchPage)
housing = URL('detail-(?P<_id>.*).htm', HousingPage)
phone = URL('(?P<urlcontact>.*)', PhonePage)
TYPES = {POSTS_TYPES.RENT: 'location-immobilier',
POSTS_TYPES.SALE: 'vente-immobilier',
POSTS_TYPES.SHARING: 'recherche-colocation',
POSTS_TYPES.FURNISHED_RENT: 'location-immobilier',
POSTS_TYPES.VIAGER: 'vente-immobilier'}
RET = {HOUSE_TYPES.HOUSE: '2',
HOUSE_TYPES.APART: '1',
HOUSE_TYPES.LAND: '3',
HOUSE_TYPES.PARKING: '10',
HOUSE_TYPES.OTHER: '14'}
def __init__(self, *args, **kwargs):
super(LogicimmoBrowser, self).__init__(*args, **kwargs)
self.session.headers['X-Requested-With'] = 'XMLHttpRequest'
def get_cities(self, pattern):
if pattern:
return self.city.go(pattern=pattern).get_cities()
def search_housings(self, type, cities, nb_rooms, area_min, area_max, cost_min, cost_max, house_types):
if type not in self.TYPES:
raise TypeNotSupported()
options = []
ret = []
if type == POSTS_TYPES.VIAGER:
ret = ['15']
else:
for house_type in house_types:
if house_type in self.RET:
ret.append(self.RET.get(house_type))
if len(ret):
options.append('groupprptypesids=%s' % ','.join(ret))
if type == POSTS_TYPES.FURNISHED_RENT:
options.append('searchoptions=4')
options.append('pricemin=%s' % (cost_min if cost_min else '0'))
if cost_max:
options.append('pricemax=%s' % cost_max)
options.append('areamin=%s' % (area_min if area_min else '0'))
if area_max:
options.append('areamax=%s' % area_max)
if nb_rooms:
if type == POSTS_TYPES.SHARING:
options.append('nbbedrooms=%s' % ','.join([str(i) for i in range(nb_rooms, 7)]))
else:
options.append('nbrooms=%s' % ','.join([str(i) for i in range(nb_rooms, 7)]))
self.search.go(type=self.TYPES.get(type, 'location-immobilier'),
cities=cities,
options='/'.join(options))
if type == POSTS_TYPES.SHARING:
return self.page.iter_sharing()
return self.page.iter_housings(query_type=type)
def get_housing(self, _id, housing=None):
return self.housing.go(_id=_id).get_housing(obj=housing)
def get_phone(self, _id):
if _id.startswith('location') or _id.startswith('vente'):
urlcontact, params = self.housing.stay_or_go(_id=_id).get_phone_url_datas()
return self.phone.go(urlcontact=urlcontact, params=params).get_phone()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -1,100 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright(C) 2014 Bezleputh
#
# This file is part of a woob module.
#
# This woob module is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This woob module is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this woob module. If not, see <http://www.gnu.org/licenses/>.
from woob.tools.backend import Module
from woob.capabilities.housing import (CapHousing, Housing, HousingPhoto,
ADVERT_TYPES)
from woob.capabilities.base import UserError
from woob import __version__ as WOOB_VERSION
from .browser import LogicimmoBrowser
__all__ = ['LogicimmoModule']
class LogicImmoCitiesError(UserError):
"""
Raised when more than 3 cities are selected
"""
def __init__(self, msg='You cannot select more than three cities'):
UserError.__init__(self, msg)
class LogicimmoModule(Module, CapHousing):
NAME = 'logicimmo'
DESCRIPTION = u'logicimmo website'
MAINTAINER = u'Bezleputh'
EMAIL = 'carton_ben@yahoo.fr'
LICENSE = 'AGPLv3+'
VERSION = WOOB_VERSION
BROWSER = LogicimmoBrowser
def get_housing(self, housing):
if isinstance(housing, Housing):
id = housing.id
else:
id = housing
housing = None
housing = self.browser.get_housing(id, housing)
return housing
def search_city(self, pattern):
return self.browser.get_cities(pattern)
def search_housings(self, query):
if(len(query.advert_types) == 1 and
query.advert_types[0] == ADVERT_TYPES.PERSONAL):
# Logic-immo is pro only
return list()
cities_names = ['%s' % c.name.replace(' ', '-') for c in query.cities if c.backend == self.name]
cities_ids = ['%s' % c.id for c in query.cities if c.backend == self.name]
if len(cities_names) == 0:
return list()
if len(cities_names) > 3:
raise LogicImmoCitiesError()
cities = ','.join(cities_names + cities_ids)
return self.browser.search_housings(query.type, cities.lower(), query.nb_rooms,
query.area_min, query.area_max,
query.cost_min, query.cost_max,
query.house_types)
def fill_housing(self, housing, fields):
if 'phone' in fields:
housing.phone = self.browser.get_phone(housing.id)
fields.remove('phone')
if len(fields) > 0:
self.browser.get_housing(housing.id, housing)
return housing
def fill_photo(self, photo, fields):
if 'data' in fields and photo.url and not photo.data:
photo.data = self.browser.open(photo.url).content
return photo
OBJECTS = {Housing: fill_housing,
HousingPhoto: fill_photo,
}

Some files were not shown because too many files have changed in this diff Show More