From e7659166be1684421fac586ed6dfce8095e6f485 Mon Sep 17 00:00:00 2001 From: "Phyks (Lucas Verney)" Date: Thu, 1 Mar 2018 17:57:45 +0100 Subject: [PATCH] Better doc --- LICENSE.txt | 19 +++++++++++ README.md | 64 ++++++++++++++++++++++++++++++++++++++ config/index.js | 2 +- cuizin/__init__.py | 44 ++------------------------ cuizin/__main__.py | 5 ++- cuizin/db.py | 22 +++++++++++-- cuizin/js_src/constants.js | 2 +- cuizin/scraping.py | 56 +++++++++++++++++++++++++++++++++ cuizin/web.py | 39 ++++++++++++++++++++--- 9 files changed, 202 insertions(+), 51 deletions(-) create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 cuizin/scraping.py diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..086f77b --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,19 @@ +Copyright 2018 - Lucas Verney (Phyks) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3c3c1b3 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +Cuizin +====== + +Cuizin is a tool wrapping around [Web Outside Of Browsers](http://weboob.org/) +to help you sort and organize recipes you find online. You can also manually +add new recipes. + + +## Installation + +``` +$ git clone … # Clone this repository +$ pip install -r requirements.txt # Install Python dependencies +$ npm install # Install JS dependencies for the frontend +$ npm run build # Build the JS dependencies +``` + +Ideally, Python dependencies should be installed in a virtual environment. + +TODO: +- `npm run build` should handle prefix + + +## Usage + +Run `python -m cuizin` to run the built-in webserver. Conversely, you can use +WSGI to serve the application directly (`application` variable is exported in +`cuizin/__main__.py`). + +You can customize the behavior of the app by passing environment variables: +* `HOST` to set the host on which the webserver should listen to (defaults to + `localhost` only). Use `HOST=0.0.0.0` to make it world-accessible. +* `PORT` to set the port on which the webserver should listen. Defaults to + `8080`. +* `DEBUG` to enable or disable the debug from Bottle (defaults to `False`). +* `WEBOOB_MODULES_PATH` to set the path to the local clone of the Weboob + modules. Default to using the `weboob-modules` package installed by `pip` + (and which you should regularly update with `pip install --upgrade + weboob-modules`). + +If you serve the app with a reverse proxy, you should serve the content of +`cuizin/dist` (static frontend files) directly, without passing it down to the +Bottle webserver. + + +## Contributing + +All contributions are welcome, feel free to open a MR. Just in case, if you +plan on working on some major new feature, please open an issue before to get +some feedbacks. + +All the code lies under `cuizin` directory. Frontend code lies under +`cuizin/js_src`. `build` and `config` folders at the root of this repository +are used for the frontend build. + +Additionnally, you can use `make dev` to spawn a development webserver to +serve the JS frontend and auto-update/auto-reload when you make changes. The +spawned JS server will be set up at `localhost:8081` and you should start the +backend Python server at `localhost:8080` with `python -m cuizin` along it. + + +## License + +This software is released under an MIT License. diff --git a/config/index.js b/config/index.js index 9b34602..e0e41f9 100644 --- a/config/index.js +++ b/config/index.js @@ -14,7 +14,7 @@ module.exports = { // Various Dev Server settings host: 'localhost', // can be overwritten by process.env.HOST - port: 8080, // can be overwritten by process.env.PORT, if port is in use, a free one will be determined + port: 8081, // can be overwritten by process.env.PORT, if port is in use, a free one will be determined autoOpenBrowser: false, errorOverlay: true, notifyOnErrors: true, diff --git a/cuizin/__init__.py b/cuizin/__init__.py index 4265ee7..46848ac 100644 --- a/cuizin/__init__.py +++ b/cuizin/__init__.py @@ -1,41 +1,3 @@ -from __future__ import absolute_import, print_function, unicode_literals -import json -import sys - -from weboob.core.ouiboube import WebNip -from weboob.tools.json import WeboobEncoder - -from cuizin import db - -BACKENDS = ['750g', 'allrecipes', 'cuisineaz', 'marmiton', 'supertoinette'] - - -def add_recipe(url, modules_path=None): - webnip = WebNip(modules_path=modules_path) - - backends = [ - webnip.load_backend( - module, - module, - params={} - ) - for module in BACKENDS - ] - - recipe = None - for backend in backends: - browser = backend.browser - if url.startswith(browser.BASEURL): - browser.location(url) - recipe = db.Recipe.from_weboob(browser.page.get_recipe()) - # Ensure URL is set - recipe.url = url - break - - if not recipe: - # TODO - recipe = db.Recipe() - recipe.url = url - - recipe.save() - return recipe +""" +Cuizin is a lightweight tool to help you manage your recipes. +""" diff --git a/cuizin/__main__.py b/cuizin/__main__.py index eadb464..f94bad5 100644 --- a/cuizin/__main__.py +++ b/cuizin/__main__.py @@ -1,3 +1,6 @@ +""" +Main entry point for Cuizin. +""" import os import peewee @@ -11,7 +14,7 @@ app = application = web.app if __name__ == '__main__': HOST = os.environ.get('CUIZIN_HOST', 'localhost') PORT = os.environ.get('CUIZIN_PORT', '8080') - DEBUG = os.environ.get('CUIZIN_DEBUG', False) + DEBUG = bool(os.environ.get('CUIZIN_DEBUG', False)) try: db.database.create_tables([db.Recipe]) diff --git a/cuizin/db.py b/cuizin/db.py index fbd4cc4..573dd7f 100644 --- a/cuizin/db.py +++ b/cuizin/db.py @@ -1,3 +1,6 @@ +""" +Database definition +""" import base64 import json @@ -15,6 +18,9 @@ database.connect() class JSONField(TextField): + """ + A Peewee database field with transparent JSON dump/load. + """ def db_value(self, value): return json.dumps(value) @@ -24,6 +30,9 @@ class JSONField(TextField): class Recipe(Model): + """ + Our base model for a recipe. + """ title = CharField() url = CharField(null=True, unique=True) author = CharField(null=True) @@ -41,25 +50,32 @@ class Recipe(Model): @staticmethod def from_weboob(obj): recipe = Recipe() + # Set fields for field in ['title', 'url', 'author', 'picture_url', 'short_description', 'preparation_time', 'cooking_time', 'ingredients', 'instructions']: value = getattr(obj, field) if value: setattr(recipe, field, value) - + # Serialize number of person recipe.nb_person = '-'.join(str(num) for num in obj.nb_person) + # Download picture and save it as a blob recipe.picture = requests.get(obj.picture_url).content return recipe def to_dict(self): + """ + Dict conversion function, for serialization in the API. + """ serialized = model_to_dict(self) - prepend_info = ( + # Dump picture as a base64 string, compatible with HTML `src` attribute + # for images. + picture_mime = ( 'data:%s;base64' % magic.from_buffer(serialized['picture'], mime=True) ) serialized['picture'] = '%s,%s' % ( - prepend_info, + picture_mime, base64.b64encode(serialized['picture']).decode('utf-8') ) return serialized diff --git a/cuizin/js_src/constants.js b/cuizin/js_src/constants.js index 86675c7..4732511 100644 --- a/cuizin/js_src/constants.js +++ b/cuizin/js_src/constants.js @@ -1,2 +1,2 @@ -export const API_URL = 'http://localhost:8080'; +export const API_URL = 'http://localhost:8080'; // TODO: Should be coming from an env variable export const FOOBAR = 'TODO'; // TODO diff --git a/cuizin/scraping.py b/cuizin/scraping.py new file mode 100644 index 0000000..32e6fc1 --- /dev/null +++ b/cuizin/scraping.py @@ -0,0 +1,56 @@ +""" +Scraping code, to fetch and add a recipe. + +This code wraps around [Web Outside Of Browsers](http://weboob.org/). +""" +from __future__ import absolute_import, print_function, unicode_literals + +import os + +from weboob.core.ouiboube import WebNip + +from cuizin import db + +# List of backends with recipe abilities in Weboob +BACKENDS = ['750g', 'allrecipes', 'cuisineaz', 'marmiton', 'supertoinette'] + + +def add_recipe(url): + """ + Add a recipe, trying to scrape from a given URL. + + :param url: URL of the recipe. + :return: A ``cuizin.db.Recipe`` model. + """ + # Eventually load modules from a local clone + MODULES_PATH = os.environ.get('WEBOOB_MODULES_PATH', None) + + # Get all backends with recipe abilities + webnip = WebNip(modules_path=MODULES_PATH) + backends = [ + webnip.load_backend( + module, + module, + params={} + ) + for module in BACKENDS + ] + + # Try to fetch the recipe with a Weboob backend + recipe = None + for backend in backends: + browser = backend.browser + if url.startswith(browser.BASEURL): + browser.location(url) + recipe = db.Recipe.from_weboob(browser.page.get_recipe()) + # Ensure URL is set + recipe.url = url + break + + if not recipe: + # TODO + recipe = db.Recipe() + recipe.url = url + + recipe.save() + return recipe diff --git a/cuizin/web.py b/cuizin/web.py index 3bb7b27..b8eefbd 100644 --- a/cuizin/web.py +++ b/cuizin/web.py @@ -1,9 +1,12 @@ import json +import os import bottle -from cuizin import add_recipe from cuizin import db +from cuizin.scraping import add_recipe + +MODULE_DIR = os.path.dirname(os.path.realpath(__file__)) app = bottle.Bottle() @@ -26,13 +29,20 @@ def enable_cors(): @app.route('/api/v1', ['GET', 'OPTIONS']) def api_v1_index(): + """ + API index route + """ return { - 'recipes': '/api/v1/recipes' + 'recipes': '/api/v1/recipes', + 'recipe': '/api/v1/recipe/:id' } @app.route('/api/v1/recipes', ['GET', 'OPTIONS']) def api_v1_recipes(): + """ + List all recipes + """ # CORS if bottle.request.method == 'OPTIONS': return '' @@ -46,6 +56,9 @@ def api_v1_recipes(): @app.post('/api/v1/recipes') def api_v1_recipes_post(): + """ + Create a new recipe from URL + """ data = json.load(bottle.request.body) if 'url' not in data: return { @@ -69,6 +82,9 @@ def api_v1_recipes_post(): @app.route('/api/v1/recipe/:id', ['GET', 'OPTIONS']) def api_v1_recipe(id): + """ + Get a given recipe from db + """ # CORS if bottle.request.method == 'OPTIONS': return '' @@ -84,6 +100,9 @@ def api_v1_recipe(id): @app.delete('/api/v1/recipe/:id', ['DELETE', 'OPTIONS']) def api_v1_recipe_delete(id): + """ + Delete a given recipe from db + """ # CORS if bottle.request.method == 'OPTIONS': return '' @@ -97,7 +116,19 @@ def api_v1_recipe_delete(id): } +@app.get('/') +def index(): + """ + Return built index.html file + """ + return bottle.static_file('index.html', + root=os.path.join(MODULE_DIR, 'dist')) + + @app.get('/static/') def get_static_files(filename): - """Get Static files""" - return bottle.static_file(filename) # TODO: root= + """ + Get Static files + """ + return bottle.static_file(filename, + root=os.path.join(MODULE_DIR, 'dist', 'static'))