diff --git a/README.md b/README.md index b4e2853..94f03a6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,154 @@ -# Project requirements +# Fullstack Price Aggregator +UK supermarket price aggregator via web scraping. This was developed for my university project as an MVP so there are aspects which don't function correctly. I may update it in the future. -* Docker -Install docker for your platform, open a terminal and cd into the project root, from here run the command "docker-compose up --build". -This will build the image \ No newline at end of file +## Features +* Python REST backend via FastAPI +* VueJS Frontend SPA with TypeScript using [this template](https://github.com/Armour/vue-typescript-admin-template). +* JWT Authentication between backend and frontend +* CLI +* [Docker](https://github.com/docker) +* [Poetry](https://github.com/python-poetry/poetry) +* Web Scraped prices via [selectorlib](https://selectorlib.com/) +* Scrape JavaScript enabled pages using [splash](https://github.com/scrapinghub/splash) + + +## Setup +Ensure you have Docker and Node.js installed. + +1. Clone this repository +2. cd into project root and run `docker-compose up --build -d`. The starts the backend and on first run will build all the necessary containers which can take a few minutes. +3. Once the backend has finished building, obtain a shell with `docker-compose exec backend bash`. +4. To initialise the database run `python manage.py createdb` from within the bash shell. + * (Optional) To seed the db with dummy data run `python manage.py seeddb` + Note all passwords are set to **password** +5. `cd` into the client directory run `npm install` which will install the required node modules. +6. run `npm run serve` which will serve the client frontend. + + +## Project URLs + +| URL | Description | +|:----------------------------------------:|:--------------------------------------------------------------------------------------------| +| [0.0.0.0:8000/api](0.0.0.0:8000/api) | Backend JSON API | +| [0.0.0.0:8000/docs](0.0.0.0:8000/docs) | Backend OpenAPI/Swagger-generated API Reference Documentation | +| [0.0.0.0:8000/redoc](0.0.0.0:8000/redoc) | Alternative interactive documentation provided by [ReDoc](https://github.com/Redocly/redoc) | +| [localhost:9527](localhost:9527) | Frontend VueJS Single Page Application | + + +## CLI +The backend includes a CLI which is heavily inspired by [Netflix's Dispatch](https://github.com/Netflix/dispatch). + +[`typer`](https://github.com/tiangolo/typer) (same author as FastAPI) was used to create a CLI for the project and is accessed via [manage.py](./server/manage.py). + +To run commands you will need to a shell running inside the backend container with: + + docker-compose run backend bash + +To see all the available commands: + + root@72293bee6b37:/app# python manage.py + Usage: manage.py [OPTIONS] COMMAND [ARGS]... + + Options: + --help Show this message and exit. + + Commands: + config Display application configuration. + createdb Creates an empty database. + createrole Add role to database. + createuser Create new user in the database. + develop Start a development server with reload. + dropdb Drop the existing database. + routes Display application routes and dependencies. + seeddb Add fake data to database. + shell Starts an interactive shell with app object imported. + +#### Routes + + root@72293bee6b37:/app# python manage.py routes + + Application Endpoints + Path Methods Dependencies + ----------------------------- --------- -------------------------------------------------------- + /api/v1/users/ GET ['RoleChecker: Roles: admin,user'] + /api/v1/users/ POST ['RoleChecker: Roles: admin,user'] + /api/v1/users/{id} GET ['RoleChecker: Roles: admin,user'] + /api/v1/users/{id} PUT ['RoleChecker: Roles: admin,user'] + /api/v1/users/{id} DELETE ['RoleChecker: Roles: admin,user'] + /api/v1/users/{id}/roles GET ['RoleChecker: Roles: admin,user'] + /api/v1/users/{id}/roles PUT ['RoleChecker: Roles: admin,user'] + /api/v1/users/{id}/shops GET ['RoleChecker: Roles: admin,user'] + /api/v1/users/{id}/shops PUT ['RoleChecker: Roles: admin,user'] + /api/v1/roles/ GET ['RoleChecker: Roles: admin,user'] + /api/v1/roles/ POST ['RoleChecker: Roles: admin,user'] + /api/v1/roles/{id} GET ['RoleChecker: Roles: admin,user'] + /api/v1/roles/{id} PUT ['RoleChecker: Roles: admin,user'] + /api/v1/roles/{id} DELETE ['RoleChecker: Roles: admin,user'] + /api/v1/shops/ GET ['RoleChecker: Roles: admin,user'] + /api/v1/shops/ POST ['RoleChecker: Roles: admin,user'] + /api/v1/shops/{id} PUT ['RoleChecker: Roles: admin,user'] + /api/v1/shops/{id} DELETE ['RoleChecker: Roles: admin,user'] + /api/v1/shops/listings/ GET ['RoleChecker: Roles: admin,user'] + /api/healthcheck GET [] + +#### Config + + python manage.py config + + Application Configuration + Setting Value(s) + -------------------------- -------------------------------------------------------------------- + APP_DIR /app/app + STATIC_DIR /app/app/static + EMAIL_TEMPLATES_DIR /app/app/static/email-templates/html + PROJECT_NAME Fastapi Backend + SERVER_HOST 0.0.0.0 + CORS_WHITELIST ['http://localhost', 'http://localhost:8000', 'http://0.0.0.0:8000'] + FASTAPI_ENV development + DEBUG False + LOG_LEVEL debug + FIRST_SUPERUSER user@example.com + FIRST_SUPERUSER_PASSWORD a5dbf43e07f4d19e5b73bc89a8f74 + USERS_OPEN_REGISTRATION True + SECRET_KEY ********** + JWT_AUTH_LIFETIME_SECONDS 604800 + JWT_EMAIL_LIFETIME_SECONDS 3600 + SMTP_USER admin@backend.com + SMTP_PASSWORD ********** + SMTP_TLS False + SMTP_SSL False + SMTP_HOST mailhog + SMTP_PORT 1025 + POSTGRES_USER postgres + POSTGRES_PASSWORD ********** + POSTGRES_HOST postgres + POSTGRES_PORT 5432 + POSTGRES_DB fastapi_backend + +## Frontend views + +### Login view + + +### Admin dashboard view + + +### User CRUD view + + +### User create view + + +### User profile view + + +### Shop CRUD view + + +### Shop select view + + +### Scraped listings view + + diff --git a/client/package-lock.json b/client/package-lock.json index 79a4b59..3c07f94 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1169,6 +1169,15 @@ "integrity": "sha512-gJJX9Jjdt3bIAePQRRjYWG20dIhAgEqonguyHxXuqALxsoDsDLimihqrSg8fXgVTJ4KZCzkfglKtwsh/8dLfbA==", "dev": true }, + "@types/codemirror": { + "version": "0.0.91", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-0.0.91.tgz", + "integrity": "sha512-FZcfBNjhVc6slo6RbtbCqYa+KTQa9sykV5OdRLqd3FeMPddVLFuqSR3KNZUbzU9qoEBudBZX0nbItJ52ml37KA==", + "dev": true, + "requires": { + "@types/tern": "*" + } + }, "@types/color-name": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", @@ -1181,6 +1190,12 @@ "integrity": "sha512-OCutwjDZ4aFS6PB1UZ988C4YgwlBHJd6wCeQqaLdmadZ/7e+w79+hbMUFC1QXDNCmdyoRfAFdm0RypzwR+Qpag==", "dev": true }, + "@types/estree": { + "version": "0.0.44", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.44.tgz", + "integrity": "sha512-iaIVzr+w2ZJ5HkidlZ3EJM8VTZb2MJLCjw3V+505yVts0gRC4UMvjw0d1HPtGqI/HQC/KdsYtayfzl+AXY2R8g==", + "dev": true + }, "@types/events": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz", @@ -1257,6 +1272,15 @@ "integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==", "dev": true }, + "@types/tern": { + "version": "0.23.3", + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.3.tgz", + "integrity": "sha512-imDtS4TAoTcXk0g7u4kkWqedB3E4qpjXzCpD2LU5M5NAXHzCDsypyvXSaG7mM8DKYkCRa7tFp4tS/lp/Wo7Q3w==", + "dev": true, + "requires": { + "@types/estree": "*" + } + }, "@types/webpack-env": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.15.1.tgz", @@ -2144,6 +2168,11 @@ "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "dev": true }, + "JSV": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/JSV/-/JSV-4.0.2.tgz", + "integrity": "sha1-0Hf2glVx+CEy+d/67Vh7QCn+/1c=" + }, "abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -3787,6 +3816,11 @@ "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" }, + "codemirror": { + "version": "5.53.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.53.2.tgz", + "integrity": "sha512-wvSQKS4E+P8Fxn/AQ+tQtJnF1qH5UOlxtugFLpubEZ5jcdH2iXTVinb+Xc/4QjshuOxRm4fUsU2QPF1JJKiyXA==" + }, "collection-visit": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", @@ -7145,6 +7179,11 @@ } } }, + "has-color": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/has-color/-/has-color-0.1.7.tgz", + "integrity": "sha1-ZxRKUmDDT8PMpnfQQdr1L+e3iy8=" + }, "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -8323,7 +8362,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz", "integrity": "sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==", - "dev": true, "requires": { "minimist": "^1.2.5" } @@ -8343,6 +8381,15 @@ "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=", "dev": true }, + "jsonlint": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/jsonlint/-/jsonlint-1.6.3.tgz", + "integrity": "sha512-jMVTMzP+7gU/IyC6hvKyWpUU8tmTkK5b3BPNuMI9U8Sit+YAWLlZwB6Y6YrdCxfg2kNz05p3XY3Bmm4m26Nv3A==", + "requires": { + "JSV": "^4.0.x", + "nomnom": "^1.5.x" + } + }, "jsprim": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", @@ -9780,6 +9827,42 @@ "integrity": "sha512-wp8zyQVwef2hpZ/dJH7SfSrIPD6YoJz6BDQDpGEkcA0s3LpAQoxBIYmfIq6QAhC1DhwsyCgTaTTcONwX8qzCuQ==", "dev": true }, + "nomnom": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/nomnom/-/nomnom-1.8.1.tgz", + "integrity": "sha1-IVH3Ikcrp55Qp2/BJbuMjy5Nwqc=", + "requires": { + "chalk": "~0.4.0", + "underscore": "~1.6.0" + }, + "dependencies": { + "ansi-styles": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-1.0.0.tgz", + "integrity": "sha1-yxAt8cVvUSPquLZ817mAJ6AnkXg=" + }, + "chalk": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-0.4.0.tgz", + "integrity": "sha1-UZmj3c0MHv4jvAjBsCewYXbgxk8=", + "requires": { + "ansi-styles": "~1.0.0", + "has-color": "~0.1.0", + "strip-ansi": "~0.1.0" + } + }, + "strip-ansi": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-0.1.1.tgz", + "integrity": "sha1-OeipjQRNFQZgq+SmgIrPcLt7yZE=" + }, + "underscore": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.6.0.tgz", + "integrity": "sha1-izixDKze9jM3uLJOT/htRa6lKag=" + } + } + }, "nopt": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.3.tgz", @@ -11370,6 +11453,11 @@ "unpipe": "1.0.0" } }, + "raw-loader": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-0.5.1.tgz", + "integrity": "sha1-DD0L6u2KAclm2Xh793goElKpeao=" + }, "read-pkg": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", @@ -11956,6 +12044,14 @@ "resolved": "https://registry.npmjs.org/screenfull/-/screenfull-5.0.2.tgz", "integrity": "sha512-cCF2b+L/mnEiORLN5xSAz6H3t18i2oHh9BA8+CQlAh5DRw2+NFAGQJOSYbcGw8B2k04g/lVvFcfZ83b3ysH5UQ==" }, + "script-loader": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/script-loader/-/script-loader-0.7.2.tgz", + "integrity": "sha512-UMNLEvgOAQuzK8ji8qIscM3GIrRCWN6MmMXGD4SD5l6cSycgGsCo0tX5xRnfQcoghqct0tjHjcykgI1PyBE2aA==", + "requires": { + "raw-loader": "~0.5.1" + } + }, "select": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz", diff --git a/client/package.json b/client/package.json index 3dda7c1..ef8b0cf 100644 --- a/client/package.json +++ b/client/package.json @@ -13,16 +13,20 @@ "dependencies": { "axios": "^0.19.2", "clipboard": "^2.0.6", + "codemirror": "^5.53.2", "core-js": "^3.6.5", "echarts": "^4.7.0", "element-ui": "^2.13.0", "fuse.js": "^5.1.0", "js-cookie": "^2.2.1", + "json5": "^2.1.3", + "jsonlint": "^1.6.3", "normalize.css": "^8.0.1", "nprogress": "^0.2.0", "path-to-regexp": "^6.1.0", "register-service-worker": "^1.7.1", "screenfull": "^5.0.2", + "script-loader": "^0.7.2", "simple-progress-webpack-plugin": "^1.1.2", "vue": "^2.6.11", "vue-class-component": "^7.2.3", @@ -35,6 +39,7 @@ }, "devDependencies": { "@types/clipboard": "^2.0.1", + "@types/codemirror": "0.0.91", "@types/js-cookie": "^2.2.5", "@types/node": "^13.11.0", "@types/nprogress": "^0.2.0", diff --git a/client/src/components/JsonEditor/index.vue b/client/src/components/JsonEditor/index.vue new file mode 100644 index 0000000..6f9e4de --- /dev/null +++ b/client/src/components/JsonEditor/index.vue @@ -0,0 +1,91 @@ +<template> + <div class="json-editor"> + <textarea ref="textarea" /> + </div> +</template> + +<script lang="ts"> +import CodeMirror, { Editor } from 'codemirror'; +import 'codemirror/addon/lint/lint.css'; +import 'codemirror/lib/codemirror.css'; +import 'codemirror/theme/elegant.css'; +import 'codemirror/mode/javascript/javascript'; +import 'codemirror/addon/lint/lint'; +import 'codemirror/addon/lint/json-lint'; +import { Component, Prop, Vue, Watch } from 'vue-property-decorator'; + +// HACK: have to use script-loader to load jsonlint +/* eslint-disable import/no-webpack-loader-syntax */ +require('script-loader!jsonlint'); + +@Component({ + name: 'JsonEditor' +}) +export default class extends Vue { + @Prop({ required: true }) private value!: string + + private jsonEditor?: Editor + + @Watch('value') + private onValueChange(value: string) { + if (this.jsonEditor) { + const editorValue = this.jsonEditor.getValue(); + if (value !== editorValue) { + this.jsonEditor.setValue(JSON.stringify(this.value, null, 2)); + } + } + } + + mounted() { + this.jsonEditor = CodeMirror.fromTextArea(this.$refs.textarea as HTMLTextAreaElement, { + lineNumbers: true, + mode: 'application/json', + gutters: ['CodeMirror-lint-markers'], + theme: 'elegant', + lint: true + }); + + this.jsonEditor.setValue(JSON.stringify(this.value, null, 2)); + this.jsonEditor.on('change', editor => { + this.$emit('changed', editor.getValue()); + this.$emit('input', editor.getValue()); + }); + } + + public setValue(value: string) { + if (this.jsonEditor) { + this.jsonEditor.setValue(value); + } + } + + public getValue() { + if (this.jsonEditor) { + return this.jsonEditor.getValue(); + } + return ''; + } +} +</script> + +<style lang="scss"> +.CodeMirror { + height: auto; + min-height: 300px; + font-family: inherit; +} + +.CodeMirror-scroll { + min-height: 300px; +} + +.cm span.cm-string { + color: #F08047; +} +</style> + +<style lang="scss" scoped> +.json-editor { + height: 100%; + position: relative; +} +</style> diff --git a/client/src/views/prices/Index.vue b/client/src/views/prices/Index.vue index 53b1a18..7f1071c 100644 --- a/client/src/views/prices/Index.vue +++ b/client/src/views/prices/Index.vue @@ -34,43 +34,70 @@ </div> <div class="search-results"> - <div - class="item" - v-for="shopResult in results" - :key="shopResult.id" + <el-row + v-for="shop in results" + :key="shop.id" + :gutter="12" > - <div - v-for="listing in shopResult.listings" - :key="listing.url" + <h2>{{ shop.name }}</h2> + <el-col + v-for="item in shop.listings" + :key="item.url" + :span="8" > <el-card - class="hover"> - <img - :src="listing.image_url" - class="image" - > - <div style="padding: 14px;"> - <span>{{ listing.name }}</span> - <div class="bottom clearfix"> - <time class="time">{{ listing.price }}</time> + class="box-card small" + shadow="hover" + > + <el-row> + <el-col :span="4"> + <div class="card-img"> + <el-image + style="width: 100px; height: 100px" + :src="item.image_url" + fit="scale-down" + lazy + /> + </div> + </el-col> + </el-row> + + <el-row> + {{ item.name }} + </el-row> + + <el-row> + <span class="item-price">£{{ item.price }}</span> + </el-row> + + <el-row> + <span class="item-price">£{{ item.price_per_unit }}</span> + </el-row> + + <el-row> + <el-col> <el-button type="text" class="button" > - View item + <el-link + :href="item.url" + type="primary" + > + View Item + </el-link> </el-button> - </div> - </div> + </el-col> + </el-row> </el-card> - </div> - </div> + </el-col> + </el-row> </div> </div> </template> <script lang="ts"> import { Component, Vue } from 'vue-property-decorator'; -import { UserMeModule } from '@/store/modules/me'; import { getShopListings } from '@/api/shops'; import { IShopListings } from '@/api/types'; import { ShopsModule } from '@/store/modules/shops'; @@ -121,8 +148,12 @@ export default class extends Vue { .el-checkbox-group { padding: 10px; } - .listing-card { - width: 150px; + .box-card { + margin: 5px; + max-width: 100rem; + .item-price { + font-weight: bold; + } } .search-terms { max-width: 700px; @@ -132,5 +163,9 @@ export default class extends Vue { size: 50px; max-width: 250px; } + .card-img { + height: auto; + max-width: 30%; + } </style> diff --git a/client/src/views/prices/Index2.vue b/client/src/views/prices/Index2.vue new file mode 100644 index 0000000..9280b6b --- /dev/null +++ b/client/src/views/prices/Index2.vue @@ -0,0 +1,166 @@ +<template> + <div class="app-container"> + <div + class="search-prices" + > + <div class="search-terms"> + <el-select + v-model="params.include" + multiple + placeholder="Select shops" + no-data-text="No shops available" + > + <el-option + v-for="shop in allShops" + :key="shop.id" + :label="shop.name" + :value="shop.id" + /> + </el-select> + <el-input + v-model="params.query" + placeholder="Query" + clearable + style="width: 200px" + /> + <el-button + :loading="loading" + type="primary" + @click="searchPrices()" + > + Search + </el-button> + </div> + </div> + + <div class="search-results"> + <el-row + v-for="shop in results" + :key="shop.id" + :gutter="12" + > + <h2>{{ shop.name }}</h2> + <el-col + v-for="item in shop.listings" + :key="item.url" + :span="8" + > + <el-card + class="box-card small" + shadow="hover" + > + <el-row> + <el-col :span="4"> + <div class="card-img"> + <el-image + style="width: 100px; height: 100px" + :src="item.image_url" + fit="scale-down" + lazy + /> + </div> + </el-col> + </el-row> + + <el-row> + {{ item.name }} + </el-row> + + <el-row> + <span class="item-price">£{{ item.price }}</span> + </el-row> + + <el-row> + <span class="item-price">£{{ item.price_per_unit }}</span> + </el-row> + + <el-row> + <el-col> + <el-button + type="text" + class="button" + > + <el-link + :href="item.url" + type="primary" + > + View Item + </el-link> + </el-button> + </el-col> + </el-row> + </el-card> + </el-col> + </el-row> + </div> + </div> +</template> + +<script lang="ts"> +import { Component, Vue } from 'vue-property-decorator'; +import { getShopListings } from '@/api/shops'; +import { IShopListings } from '@/api/types'; +import { ShopsModule } from '@/store/modules/shops'; + +const jsonData = '[{"id":1,"name":"aldi","listings":[{"name":"Fresh Tagliatelle Pasta","url":"https:\\/\\/www.aldi.co.uk\\/fresh-tagliatelle-pasta\\/p\\/016774333996602","price":"1.192.38perkg","price_per_unit":null,"image_url":"https:\\/\\/cdn.aldi-digital.co.uk\\/\\/Fresh-Tagliatelle-Pasta-A.jpg?o=PDOPN2HcYse7V5Fh%24nrTgxqzg6oj&V=QuE%24&w=178&h=223&p=2&q=88, https:\\/\\/cdn.aldi-digital.co.uk\\/\\/Fresh-Tagliatelle-Pasta-A.jpg?o=PDOPN2HcYse7V5Fh%24nrTgxqzg6oj&V=QuE%24&w=356&h=446&p=2&q=83 2x"},{"name":"Cheese Pasta Salad","url":"https:\\/\\/www.aldi.co.uk\\/cheese-pasta-salad\\/p\\/002460198287502","price":"0.9924.8pper100g","price_per_unit":null,"image_url":"https:\\/\\/cdn.aldi-digital.co.uk\\/\\/Cheese-Pasta-Salad-A.jpg?o=2mt6etRnUjpczXatInBzb6orx78j&V=NP9g&w=178&h=223&p=2&q=88, https:\\/\\/cdn.aldi-digital.co.uk\\/\\/Cheese-Pasta-Salad-A.jpg?o=2mt6etRnUjpczXatInBzb6orx78j&V=NP9g&w=356&h=446&p=2&q=83 2x"},{"name":"Carbonara Pasta Sauce","url":"https:\\/\\/www.aldi.co.uk\\/carbonara-pasta-sauce\\/p\\/002806000353200","price":"0.5216.8pper100g","price_per_unit":null,"image_url":"https:\\/\\/cdn.aldi-digital.co.uk\\/\\/Carbonara-Pasta-Sauce-A.jpg?o=UnLYVAmb7OB4c3tesLdVs%40%4087vAj&V=YSDf&w=178&h=223&p=2&q=88, https:\\/\\/cdn.aldi-digital.co.uk\\/\\/Carbonara-Pasta-Sauce-A.jpg?o=UnLYVAmb7OB4c3tesLdVs%40%4087vAj&V=YSDf&w=356&h=446&p=2&q=83 2x"},{"name":"Tuna Pasta Bake","url":"https:\\/\\/www.aldi.co.uk\\/tuna-pasta-bake\\/p\\/081857200400800","price":"1.894.73perkg","price_per_unit":null,"image_url":"https:\\/\\/cdn.aldi-digital.co.uk\\/\\/Tuna-Pasta-Bake-A.jpg?o=t%40kJu1l6U6pWwwJabcSEMvRBZS4j&V=138d&w=178&h=223&p=2&q=88, https:\\/\\/cdn.aldi-digital.co.uk\\/\\/Tuna-Pasta-Bake-A.jpg?o=t%40kJu1l6U6pWwwJabcSEMvRBZS4j&V=138d&w=356&h=446&p=2&q=83 2x"},{"name":"Carbonara Pasta Sauce","url":"https:\\/\\/www.aldi.co.uk\\/carbonara-pasta-sauce\\/p\\/077398279806002","price":"0.992.83perkg","price_per_unit":null,"image_url":"https:\\/\\/cdn.aldi-digital.co.uk\\/\\/Carbonara-Pasta-Sauce-A.jpg?o=viutNWO38%40hx0Jvb7c4UiOmj0JEj&V=YSDf&w=178&h=223&p=2&q=88, https:\\/\\/cdn.aldi-digital.co.uk\\/\\/Carbonara-Pasta-Sauce-A.jpg?o=viutNWO38%40hx0Jvb7c4UiOmj0JEj&V=YSDf&w=356&h=446&p=2&q=83 2x"},{"name":"Fresh Fusilli Pasta","url":"https:\\/\\/www.aldi.co.uk\\/fresh-fusilli-pasta\\/p\\/016774333996401","price":"1.192.38perkg","price_per_unit":null,"image_url":"https:\\/\\/cdn.aldi-digital.co.uk\\/\\/Fresh-Fusilli-Pasta-A.jpg?o=8%40H%24%24cncr2y8N1fQ8%40q8O4vkzM4j&V=%40M%243&w=178&h=223&p=2&q=88, https:\\/\\/cdn.aldi-digital.co.uk\\/\\/Fresh-Fusilli-Pasta-A.jpg?o=8%40H%24%24cncr2y8N1fQ8%40q8O4vkzM4j&V=%40M%243&w=356&h=446&p=2&q=83 2x"},{"name":"Cheesy Macaroni Pasta","url":"https:\\/\\/www.aldi.co.uk\\/cheesy-macaroni-pasta\\/p\\/070916066805700","price":"0.8946.8pper100g","price_per_unit":null,"image_url":"https:\\/\\/cdn.aldi-digital.co.uk\\/\\/Cheesy-Macaroni-Pasta-A.jpg?o=3k8TrZPRA8VzbI86y7ZyLKMWKZAj&V=2Q%40z&w=178&h=223&p=2&q=88, https:\\/\\/cdn.aldi-digital.co.uk\\/\\/Cheesy-Macaroni-Pasta-A.jpg?o=3k8TrZPRA8VzbI86y7ZyLKMWKZAj&V=2Q%40z&w=356&h=446&p=2&q=83 2x"},{"name":"Chicken & Mushroom Pasta & Sauce","url":"https:\\/\\/www.aldi.co.uk\\/chicken-%26-mushroom-pasta-%26-sauce\\/p\\/043698254081501","price":"0.3933.9pper100g","price_per_unit":null,"image_url":"https:\\/\\/cdn.aldi-digital.co.uk\\/\\/Chicken-&-Mushroom-Pasta-&-Sauce-A.jpg?o=7a%40e9jK6xR7djj9w98s57poauCoj&V=oMTt&w=178&h=223&p=2&q=88, https:\\/\\/cdn.aldi-digital.co.uk\\/\\/Chicken-&-Mushroom-Pasta-&-Sauce-A.jpg?o=7a%40e9jK6xR7djj9w98s57poauCoj&V=oMTt&w=356&h=446&p=2&q=83 2x"},{"name":"Macaroni Cheese Pasta & Sauce","url":"https:\\/\\/www.aldi.co.uk\\/macaroni-cheese-pasta-%26-sauce\\/p\\/043694254081301","price":"0.3933.9pper100g","price_per_unit":null,"image_url":"https:\\/\\/cdn.aldi-digital.co.uk\\/\\/Macaroni-Cheese-Pasta-&-Sauce-A.jpg?o=XqKxTWR6Mc3Xt%40YEYf97BFWxHUMj&V=dtEQ&w=178&h=223&p=2&q=88, https:\\/\\/cdn.aldi-digital.co.uk\\/\\/Macaroni-Cheese-Pasta-&-Sauce-A.jpg?o=XqKxTWR6Mc3Xt%40YEYf97BFWxHUMj&V=dtEQ&w=356&h=446&p=2&q=83 2x"},{"name":"Cheese & Broccoli Pasta & Sauce","url":"https:\\/\\/www.aldi.co.uk\\/cheese-%26-broccoli-pasta-%26-sauce\\/p\\/043698003281800","price":"0.3933.9pper100g","price_per_unit":null,"image_url":"https:\\/\\/cdn.aldi-digital.co.uk\\/\\/Cheese-&-Broccoli-Pasta-&-Sauce-A.jpg?o=0vwg%24ZlSTGvRYs99I6IFcSqsik0j&V=hBhd&w=178&h=223&p=2&q=88, https:\\/\\/cdn.aldi-digital.co.uk\\/\\/Cheese-&-Broccoli-Pasta-&-Sauce-A.jpg?o=0vwg%24ZlSTGvRYs99I6IFcSqsik0j&V=hBhd&w=356&h=446&p=2&q=83 2x"}]},{"id":2,"name":"amazon_pantry","listings":[{"name":"Dolmio Original Bolognese Pasta Sauce, 2 x 500 g","url":"https:\\/\\/www.amazon.co.uk\\/Dolmio-Original-Bolognese-Pasta-Sauce\\/dp\\/B073QM1L3Q\\/ref=sr_1_1?dchild=1&keywords=pasta&qid=1588413654&s=pantry&sr=8-1&srs=5782660031","price":"2.00","price_per_unit":"2.00\\/kg","image_url":"https:\\/\\/m.media-amazon.com\\/images\\/I\\/91bsI1OZu6L._AC_UL320_.jpg"},{"name":"DOLMIO Bolognese 750g Extra Onion & Garlic","url":"https:\\/\\/www.amazon.co.uk\\/Dolmio-Sauce-Bolognese-Intense-Garlic\\/dp\\/B0176G8DPO\\/ref=sr_1_2?dchild=1&keywords=pasta&qid=1588413654&s=pantry&sr=8-2&srs=5782660031","price":"2.00","price_per_unit":"2.67\\/kg","image_url":"https:\\/\\/m.media-amazon.com\\/images\\/I\\/81w2DU28wFL._AC_UL320_.jpg"},{"name":"Dolmio Pasta Bake Creamy Tomato Pasta Sauce, 500 grams","url":"https:\\/\\/www.amazon.co.uk\\/Dolmio-Sauce-Pasta-Creamy-Tomato\\/dp\\/B0148K6FK6\\/ref=sr_1_3?dchild=1&keywords=pasta&qid=1588413654&s=pantry&sr=8-3&srs=5782660031","price":"1.75","price_per_unit":"3.50\\/kg","image_url":"https:\\/\\/m.media-amazon.com\\/images\\/I\\/81TWVLtaJlL._AC_UL320_.jpg"},{"name":"Uncle Ben\'s Spicy Mexican Rice 3 x 250g (750g)","url":"https:\\/\\/www.amazon.co.uk\\/Uncle-Bens-Spicy-Mexican-Rice\\/dp\\/B073H9JJL3\\/ref=sr_1_4?dchild=1&keywords=pasta&qid=1588413654&s=pantry&sr=8-4&srs=5782660031","price":"4.00","price_per_unit":"5.33\\/kg","image_url":"https:\\/\\/m.media-amazon.com\\/images\\/I\\/81V02PG4gXL._AC_UL320_.jpg"},{"name":"Domestos Original Thick Bleach, Toilet Disinfectant And Cleaner For Home And Bathroom, Removes Stains From Surafces And Kills 99.9% Of Bacteria And Germs (750 ml)","url":"https:\\/\\/www.amazon.co.uk\\/Domestos-100444506-Bleach-Original-750ml\\/dp\\/B014G50572\\/ref=sr_1_5?dchild=1&keywords=pasta&qid=1588413654&s=pantry&sr=8-5&srs=5782660031","price":"1.00","price_per_unit":"1.33\\/l","image_url":"https:\\/\\/m.media-amazon.com\\/images\\/I\\/71ERGDc7QjL._AC_UL320_.jpg"},{"name":"Loyd Grossman Tomato and Roastedgarlic Sauce, 350g","url":"https:\\/\\/www.amazon.co.uk\\/Loyd-Grossman-Tomato-Roastedgarlic-Sauce\\/dp\\/B014G2KM6Y\\/ref=sr_1_6?dchild=1&keywords=pasta&qid=1588413654&s=pantry&sr=8-6&srs=5782660031","price":"1.40","price_per_unit":"4.00\\/kg","image_url":"https:\\/\\/m.media-amazon.com\\/images\\/I\\/71rJ2hKwCIL._AC_UL320_.jpg"},{"name":"Colgate Cavity Protection Toothpaste Pump, 100 milliliters","url":"https:\\/\\/www.amazon.co.uk\\/Colgate-Cavity-Protection-Toothpaste-milliliters\\/dp\\/B014DDL4SQ\\/ref=sr_1_7?dchild=1&keywords=pasta&qid=1588413654&s=pantry&sr=8-7&srs=5782660031","price":"1.50","price_per_unit":"1.50\\/100ml","image_url":"https:\\/\\/m.media-amazon.com\\/images\\/I\\/71jrs72NWEL._AC_UL320_.jpg"},{"name":"Old El Paso Mexican Smoky BBQ Fajita Dinner Kit, 500g","url":"https:\\/\\/www.amazon.co.uk\\/Old-El-Paso-Mexican-Fajita\\/dp\\/B0161I24WG\\/ref=sr_1_8?dchild=1&keywords=pasta&qid=1588413654&s=pantry&sr=8-8&srs=5782660031","price":"2.75","price_per_unit":"5.50\\/kg","image_url":"https:\\/\\/m.media-amazon.com\\/images\\/I\\/91yzjm9uwFL._AC_UL320_.jpg"},{"name":"Heinz Baked Beans in Tomato Sauce, 415 g (Pack of 4)","url":"https:\\/\\/www.amazon.co.uk\\/Heinz-Baked-Beans-Tomato-Sauce\\/dp\\/B015O5BZDQ\\/ref=sr_1_9?dchild=1&keywords=pasta&qid=1588413654&s=pantry&sr=8-9&srs=5782660031","price":"2.50","price_per_unit":"1.51\\/kg","image_url":"https:\\/\\/m.media-amazon.com\\/images\\/I\\/81-JGC5d+dL._AC_UL320_.jpg"},{"name":"DOLMIO Sun-Ripened Tomato and Chilli Pasta Sauce, 350 g","url":"https:\\/\\/www.amazon.co.uk\\/DOLMIO-Sun-Ripened-Tomato-Chilli-Pasta\\/dp\\/B073H734C6\\/ref=sr_1_10?dchild=1&keywords=pasta&qid=1588413654&s=pantry&sr=8-10&srs=5782660031","price":"1.75","price_per_unit":"5.00\\/kg","image_url":"https:\\/\\/m.media-amazon.com\\/images\\/I\\/81XiJ8FcoBL._AC_UL320_.jpg"}]},{"id":3,"name":"asda","listings":null},{"id":4,"name":"iceland","listings":[{"name":"Pasta Reggia di Caserta Durum Semolina Pasta Spaghetti 1kg","url":"https:\\/\\/www.iceland.co.uk\\/p\\/pasta-reggia-di-caserta-durum-semolina-pasta-spaghetti-1kg\\/63445.html","price":"1.00","price_per_unit":"1.00\\/1kg","image_url":"https:\\/\\/assets.iceland.co.uk\\/i\\/iceland\\/Reggia_1kg_Spaghetti_Pasta_63445.jpg?$producttile$"},{"name":"Batchelors Pasta \'n\' Sauce Cheese & Broccoli 99g","url":"https:\\/\\/www.iceland.co.uk\\/p\\/batchelors-pasta-n-sauce-cheese-and-broccoli-99g\\/58040.html","price":"0.80","price_per_unit":"8.08\\/1kg","image_url":"https:\\/\\/assets.iceland.co.uk\\/i\\/iceland\\/batchelors_pasta_n_sauce_cheese_broccoli_99g_58040_T5.jpg?$producttile$"},{"name":"Pot Noodle Original Curry Standard 90g","url":"https:\\/\\/www.iceland.co.uk\\/p\\/pot-noodle-original-curry-standard-90g\\/40880.html","price":"1.00","price_per_unit":"11.11\\/1kg","image_url":"https:\\/\\/assets.iceland.co.uk\\/i\\/iceland\\/Pot_Noodle_90gm_Curry_Pot_40880.jpg?$producttile$"},{"name":"Pasta Reggia Fusilli 1kg","url":"https:\\/\\/www.iceland.co.uk\\/p\\/pasta-reggia-fusilli-1kg\\/63444.html","price":"1.00","price_per_unit":"1.00\\/1kg","image_url":"https:\\/\\/assets.iceland.co.uk\\/i\\/iceland\\/Reggia_1kg_Fusilli_Pasta_63444.jpg?$producttile$"},{"name":"Dolmio Bolognese Onion and Garlic Pasta Sauce 500g","url":"https:\\/\\/www.iceland.co.uk\\/p\\/dolmio-bolognese-onion-and-garlic-pasta-sauce-500g\\/37288.html","price":"1.70","price_per_unit":"34p\\/100g","image_url":"https:\\/\\/assets.iceland.co.uk\\/i\\/iceland\\/Dolmio_500g_Intense_Garlic_37288.jpg?$producttile$"},{"name":"Branston Baked Beans in a Rich and Tasty Tomato Sauce 4 x 410g","url":"https:\\/\\/www.iceland.co.uk\\/p\\/branston-baked-beans-in-a-rich-and-tasty-tomato-sauce-4-x-410g\\/55372.html","price":"1.50","price_per_unit":"91p\\/1kg","image_url":"https:\\/\\/assets.iceland.co.uk\\/i\\/iceland\\/Branston_4_X4_Beans_55372.jpg?$producttile$"},{"name":"Dolmio Lasagne Creamy White Sauce 470g","url":"https:\\/\\/www.iceland.co.uk\\/p\\/dolmio-lasagne-creamy-white-sauce-470g\\/28118.html","price":"1.70","price_per_unit":"36p\\/100g","image_url":"https:\\/\\/assets.iceland.co.uk\\/i\\/iceland\\/dolmio_lasagne_creamy_white_sauce_470g_28118_T1.jpg?$producttile$"},{"name":"Pasta Reggia Durum Semolina Pasta 1kg","url":"https:\\/\\/www.iceland.co.uk\\/p\\/pasta-reggia-durum-semolina-pasta-1kg\\/63443.html","price":"1.00","price_per_unit":"1.00\\/1kg","image_url":"https:\\/\\/assets.iceland.co.uk\\/i\\/iceland\\/Reggia_1kg_Penne_Pasta_63443.jpg?$producttile$"},{"name":"Dolmio Bolognese Pasta Sauce 500g","url":"https:\\/\\/www.iceland.co.uk\\/p\\/dolmio-bolognese-pasta-sauce-500g\\/3447.html","price":"1.70","price_per_unit":"34p\\/100g","image_url":"https:\\/\\/assets.iceland.co.uk\\/i\\/iceland\\/dolmio_bolognese_pasta_sauce_500g_3447_T517.jpg?$producttile$"},{"name":"Napolina Five Cheese Tortellini Egg Pasta 400g","url":"https:\\/\\/www.iceland.co.uk\\/p\\/napolina-five-cheese-tortellini-egg-pasta-400g\\/78602.html","price":"1.75","price_per_unit":"4.38\\/1kg","image_url":"https:\\/\\/assets.iceland.co.uk\\/i\\/iceland\\/napolina_five_cheese_tortellini_egg_pasta_400g_78602_T1.jpg?$producttile$"}]},{"id":5,"name":"morrisons","listings":[{"name":"Morrisons Free From Fusilli 500g","url":"https:\\/\\/groceries.morrisons.com\\/products\\/morrisons-free-from-fusilli-115683011","price":"0.60","price_per_unit":"1.20\\/kg","image_url":"https:\\/\\/groceries.morrisons.com\\/productImages\\/115\\/115683011_0_150x150.jpg?identifier=880b06c864fc72579e1cf9876cb26806"},{"name":"Napolina Fusilli 500g","url":"https:\\/\\/groceries.morrisons.com\\/products\\/napolina-fusilli-214994011","price":"1","price_per_unit":"2.00\\/kg","image_url":"https:\\/\\/groceries.morrisons.com\\/productImages\\/214\\/214994011_0_150x150.jpg?identifier=560bb2a3308f55e7ac4a02e2d9a7bafb"},{"name":"Morrisons Wholewheat Fusiili 500g","url":"https:\\/\\/groceries.morrisons.com\\/products\\/morrisons-wholewheat-fusiili-209572011","price":"0.55","price_per_unit":"1.10\\/kg","image_url":"https:\\/\\/groceries.morrisons.com\\/productImages\\/209\\/209572011_0_150x150.jpg?identifier=07edf5dc2e0ee269d4ed50f283729c97"},{"name":"Morrisons Fusilli 3kg","url":"https:\\/\\/groceries.morrisons.com\\/products\\/morrisons-fusilli-215025011","price":"2.90","price_per_unit":"96.7p\\/kg","image_url":"https:\\/\\/groceries.morrisons.com\\/productImages\\/215\\/215025011_0_150x150.jpg?identifier=ca55f96573a6dfeab7c060dc1535a281"},{"name":"Napolina Wholewheat Fusilli 500g","url":"https:\\/\\/groceries.morrisons.com\\/products\\/napolina-wholewheat-fusilli-114196011","price":"1.35","price_per_unit":"2.70\\/kg","image_url":"https:\\/\\/groceries.morrisons.com\\/productImages\\/114\\/114196011_0_150x150.jpg?identifier=ac6e2c451cf348c6cc8dae4a9e97beed"},{"name":"Morrisons The Best Fusilli Gigante 500g","url":"https:\\/\\/groceries.morrisons.com\\/products\\/morrisons-the-best-fusilli-gigante-347715011","price":"1.70","price_per_unit":"3.40\\/kg","image_url":"https:\\/\\/groceries.morrisons.com\\/productImages\\/347\\/347715011_0_150x150.jpg?identifier=bcb867af066076813dcee3e199d4cdd2"},{"name":"Morrisons The Best Trottole Pasta 500g","url":"https:\\/\\/groceries.morrisons.com\\/products\\/morrisons-the-best-trottole-pasta-372760011","price":"1.75","price_per_unit":"3.50\\/kg","image_url":"https:\\/\\/groceries.morrisons.com\\/productImages\\/372\\/372760011_0_150x150.jpg?identifier=617656667739cbae15ec6cf602312b2b"},{"name":"Morrisons The Best Fusillta Casareccia Pasta 500g","url":"https:\\/\\/groceries.morrisons.com\\/products\\/morrisons-the-best-fusillta-casareccia-pasta-372758011","price":"1.75","price_per_unit":"3.50\\/kg","image_url":"https:\\/\\/groceries.morrisons.com\\/productImages\\/372\\/372758011_0_150x150.jpg?identifier=1d5ac1d44e148e3cf6d19a05bdd5064f"},{"name":"Morrisons Wholefoods Vegetable Mix with Pasta 500g","url":"https:\\/\\/groceries.morrisons.com\\/products\\/morrisons-wholefoods-vegetable-mix-with-pasta-215689011","price":"0.70","price_per_unit":"1.40\\/kg","image_url":"https:\\/\\/groceries.morrisons.com\\/productImages\\/215\\/215689011_0_150x150.jpg?identifier=b190f75c4ddcf904cd0ca908dc82cb4b"},{"name":"Napolina Fusilli Bronze Die Pasta 500g","url":"https:\\/\\/groceries.morrisons.com\\/products\\/napolina-fusilli-bronze-die-pasta-276179011","price":"1.88","price_per_unit":"3.76\\/kg","image_url":"https:\\/\\/groceries.morrisons.com\\/productImages\\/276\\/276179011_0_150x150.jpg?identifier=736e08685a1c7ae206c1f99c69a1e029"}]},{"id":6,"name":"sainsburys","listings":[{"name":"Why not try Sainsbury\'s Pomodoro Sauce, Taste the Difference 350g","url":"https:\\/\\/www.sainsburys.co.uk\\/shop\\/gb\\/groceries\\/product\\/details\\/sainsburys-pomodoro-sauce--taste-the-difference-350g","price":"2.00\\/unit","price_per_unit":"5.71\\/kg","image_url":"https:\\/\\/www.sainsburys.co.uk\\/wcsstore7.46hf.15\\/ExtendedSitesCatalogAssetStore\\/images\\/catalog\\/productImages\\/66\\/0000001745566\\/0000001745566_L.jpeg"},{"name":"Why not try Batchelors Pasta \'n\' Sauce, Cheese & Broccoli 99g","url":"https:\\/\\/www.sainsburys.co.uk\\/shop\\/gb\\/groceries\\/product\\/details\\/batchelors-pasta---sauce--cheese---broccoli-123g","price":"1.05\\/unit","price_per_unit":"10.61\\/kg","image_url":"https:\\/\\/www.sainsburys.co.uk\\/wcsstore7.46hf.15\\/ExtendedSitesCatalogAssetStore\\/images\\/catalog\\/productImages\\/09\\/5000354404009\\/5000354404009_L.jpeg"},{"name":"Sainsbury\'s Fusilli 1kg","url":"https:\\/\\/www.sainsburys.co.uk\\/shop\\/gb\\/groceries\\/product\\/details\\/sainsburys-fusilli-1kg","price":"1.10\\/unit","price_per_unit":"1.10\\/kg","image_url":"https:\\/\\/www.sainsburys.co.uk\\/wcsstore7.46hf.15\\/ExtendedSitesCatalogAssetStore\\/images\\/catalog\\/productImages\\/93\\/0000000490993\\/0000000490993_L.jpeg"},{"name":"Sainsbury\'s Penne 1kg","url":"https:\\/\\/www.sainsburys.co.uk\\/shop\\/gb\\/groceries\\/product\\/details\\/sainsburys-penne-rigate--italian-1kg","price":"1.10\\/unit","price_per_unit":"1.10\\/kg","image_url":"https:\\/\\/www.sainsburys.co.uk\\/wcsstore7.46hf.15\\/ExtendedSitesCatalogAssetStore\\/images\\/catalog\\/productImages\\/57\\/0000000536257\\/0000000536257_L.jpeg"},{"name":"Napolina Spaghetti 500g","url":"https:\\/\\/www.sainsburys.co.uk\\/shop\\/gb\\/groceries\\/product\\/details\\/napolina-spaghetti-500g","price":"1.30\\/unit","price_per_unit":"2.60\\/kg","image_url":"https:\\/\\/www.sainsburys.co.uk\\/wcsstore7.46hf.15\\/ExtendedSitesCatalogAssetStore\\/images\\/catalog\\/productImages\\/72\\/5000184592372\\/5000184592372_L.jpeg"},{"name":"Sainsbury\'s Fresh Gnocchi 500g","url":"https:\\/\\/www.sainsburys.co.uk\\/shop\\/gb\\/groceries\\/product\\/details\\/sainsburys-fresh-gnocchi-500g","price":"1.70\\/unit","price_per_unit":"3.40\\/kg","image_url":"https:\\/\\/www.sainsburys.co.uk\\/wcsstore7.46hf.15\\/ExtendedSitesCatalogAssetStore\\/images\\/catalog\\/productImages\\/86\\/0000000636186\\/0000000636186_L.jpeg"},{"name":"Sainsbury\'s Fresh Egg Fusilli 500g","url":"https:\\/\\/www.sainsburys.co.uk\\/shop\\/gb\\/groceries\\/product\\/details\\/sainsburys-fresh-egg-fusilli-500g","price":"1.70\\/unit","price_per_unit":"3.40\\/kg","image_url":"https:\\/\\/www.sainsburys.co.uk\\/wcsstore7.46hf.15\\/ExtendedSitesCatalogAssetStore\\/images\\/catalog\\/productImages\\/58\\/0000001143058\\/0000001143058_L.jpeg"},{"name":"Sainsbury\'s Fusilli 500g","url":"https:\\/\\/www.sainsburys.co.uk\\/shop\\/gb\\/groceries\\/product\\/details\\/sainsburys-fusilli--italian-500g","price":"0.60","price_per_unit":"1.20\\/kg","image_url":"https:\\/\\/www.sainsburys.co.uk\\/wcsstore7.46hf.15\\/ExtendedSitesCatalogAssetStore\\/images\\/catalog\\/productImages\\/18\\/0000000321518\\/0000000321518_L.jpeg"},{"name":"Sainsbury\'s Fresh Egg Penne 500g","url":"https:\\/\\/www.sainsburys.co.uk\\/shop\\/gb\\/groceries\\/product\\/details\\/sainsburys-fresh-penne-pasta-500g","price":"1.70\\/unit","price_per_unit":"3.40\\/kg","image_url":"https:\\/\\/www.sainsburys.co.uk\\/wcsstore7.46hf.15\\/ExtendedSitesCatalogAssetStore\\/images\\/catalog\\/productImages\\/94\\/0000000636094\\/0000000636094_L.jpeg"},{"name":"Sainsbury\'s Linguine 500g","url":"https:\\/\\/www.sainsburys.co.uk\\/shop\\/gb\\/groceries\\/product\\/details\\/sainsburys-linguine-500g","price":"0.60","price_per_unit":"1.20\\/kg","image_url":"https:\\/\\/www.sainsburys.co.uk\\/wcsstore7.46hf.15\\/ExtendedSitesCatalogAssetStore\\/images\\/catalog\\/productImages\\/24\\/0000000579124\\/0000000579124_L.jpeg"}]},{"id":7,"name":"tesco","listings":[{"name":"Tesco Penne Pasta Quills 500G","url":null,"price":"0.53","price_per_unit":"1.06\\/kg","image_url":"https:\\/\\/img.tesco.com\\/Groceries\\/pi\\/845\\/5000119319845\\/IDShot_225x225.jpg"},{"name":"Tesco Penne 300G","url":null,"price":"1.25","price_per_unit":"4.17\\/kg","image_url":"https:\\/\\/img.tesco.com\\/Groceries\\/pi\\/937\\/5057545806937\\/IDShot_225x225.jpg"},{"name":"Tesco Short Spaghetti Pasta 500G","url":null,"price":"0.53","price_per_unit":"1.06\\/kg","image_url":"https:\\/\\/img.tesco.com\\/Groceries\\/pi\\/182\\/5000119117182\\/IDShot_225x225.jpg"},{"name":"Hearty Food Co. Spaghetti Pasta 500G","url":null,"price":"0.20","price_per_unit":"0.40\\/kg","image_url":"https:\\/\\/img.tesco.com\\/Groceries\\/pi\\/514\\/5057545092514\\/IDShot_225x225.jpg"},{"name":"Tesco Whole Wheat Fusilli Pasta 500G","url":null,"price":"0.53","price_per_unit":"1.06\\/kg","image_url":"https:\\/\\/img.tesco.com\\/Groceries\\/pi\\/906\\/5000119319906\\/IDShot_225x225.jpg"},{"name":"Tesco Fusilli Pasta Twists 1Kg","url":null,"price":null,"price_per_unit":null,"image_url":"https:\\/\\/img.tesco.com\\/Groceries\\/pi\\/305\\/5000119532305\\/IDShot_225x225.jpg"},{"name":"Tesco Fusilli 300G","url":null,"price":"1.25","price_per_unit":"0.42\\/100g","image_url":"https:\\/\\/img.tesco.com\\/Groceries\\/pi\\/579\\/5057753722579\\/IDShot_225x225.jpg"},{"name":"Napolina Fusilli Pasta 500G","url":null,"price":"1.28","price_per_unit":"2.56\\/kg","image_url":"https:\\/\\/img.tesco.com\\/Groceries\\/pi\\/402\\/5000184592402\\/IDShot_225x225.jpg"},{"name":"Napolina Fusilli Pasta 1Kg","url":null,"price":"2.28","price_per_unit":"2.28\\/kg","image_url":"https:\\/\\/img.tesco.com\\/Groceries\\/pi\\/458\\/5000232823458\\/IDShot_225x225.jpg"},{"name":"Napolina Penne Pasta 1Kg","url":null,"price":"2.28","price_per_unit":"2.28\\/kg","image_url":"https:\\/\\/img.tesco.com\\/Groceries\\/pi\\/397\\/5000232823397\\/IDShot_225x225.jpg"}]}]'; + +@Component({ + name: 'Prices', + components: { + } +}) +export default class extends Vue { + private loading = false; + private params = { + include: [], + query: '', + limit: 10 + } + + private results: IShopListings[] = []; + + get allShops() { + return ShopsModule.shops; + } + + created() { + this.getData(); + } + + private async getData() { + await ShopsModule.GetShops({}); + } + + private async searchPrices() { + this.loading = true; + this.results = JSON.parse(jsonData); + this.loading = false; + } +} +</script> + +<style lang="scss" scoped> + .el-checkbox-group { + padding: 10px; + } + .box-card { + margin: 5px; + max-width: 100rem; + .item-price { + font-weight: bold; + } + } + .search-terms { + max-width: 700px; + margin: 10px; + } + .item { + size: 50px; + max-width: 250px; + } + .card-img { + height: auto; + max-width: 30%; + } + +</style> diff --git a/client/src/views/shops/CreateShop.vue b/client/src/views/shops/CreateShop.vue index c462d87..6b25d5b 100644 --- a/client/src/views/shops/CreateShop.vue +++ b/client/src/views/shops/CreateShop.vue @@ -1,109 +1,138 @@ <template> <div class="app-container"> - <el-table - :data="shops" - style="width: 100%" - > - <el-table-column - v-for="prop in tableConfig.props" - :key="prop.name" - :label="prop.label" - :align="prop.align" - :min-width="prop.minWidth" + <el-card class="box-card"> + <div + slot="header" + class="clearfix" > - <template slot-scope="{row}"> - <span v-if="prop.type === 'text'"> - {{ row[prop.name] }} - </span> - <span v-if="prop.type === 'timestamp'"> - {{ new Date(row[prop.name]).toDateString() }} - </span> - <span v-if="prop.type === 'tag'"> - <el-tag - :type="prop.tag[row[prop.name]]" - > - {{ row[prop.name] }} - </el-tag> - </span> - </template> - </el-table-column> - - <el-table-column - v-if="tableConfig.actions" - fixed="right" - label="Actions" + <span>Create a new role</span> + </div> + <el-form + ref="loginForm" + :model="createForm" + :rules="createRules" + autocomplete="on" + label-position="left" > - <template slot-scope="{row}"> - <el-button - type="primary" - plain - size="small" - icon="el-icon-edit" - @click="$router.push('/users/edit/' + row.id)" - > - Edit - </el-button> + <el-form-item + prop="name" + label="Shop Name" + > + <el-input + v-model="createForm.name" + type="text" + /> + </el-form-item> + + <el-form-item + prop="baseUrl" + label="Base URL" + > + <el-input + v-model="createForm.baseUrl" + type="text" + /> + </el-form-item> + + <el-form-item + prop="queryUrl" + label="Query URL" + > + <el-input + v-model="createForm.queryUrl" + type="text" + /> + </el-form-item> + + <el-form-item + prop="renderJS" + label="Render JavaScript" + > + <el-switch v-model="createForm.renderJS" /> + </el-form-item> - <el-button - type="danger" - plain - size="small" - icon="el-icon-delete" - @click="handleDeleteDialog(row)" - > - Delete - </el-button> - </template> - </el-table-column> - </el-table> + <el-form-item + prop="selectors" + label="CSS Selectors" + > + <div class="editor-container"> + <json-editor + ref="jsonEditor" + v-model="createForm.selectorJson" + /> + </div> + </el-form-item> + + <el-button + :loading="loading" + type="primary" + @click="handleCreate" + > + Create + </el-button> + + <el-button + @click="$router.back()" + > + Cancel + </el-button> + </el-form> + </el-card> </div> </template> <script lang="ts"> import { Component, Vue } from 'vue-property-decorator'; -import { ShopsModule } from '@/store/modules/shops'; +import { Form as ElForm, Input } from 'element-ui'; +import JsonEditor from '@/components/JsonEditor/index.vue'; @Component({ name: 'CreateShop', - components: {} + components: { + JsonEditor + } }) export default class extends Vue { - private loading = true; + private loading = false; - private tableConfig = { - props: [ - { name: 'id', type: 'text', label: 'ID', align: 'center', minWidth: 20 }, - { name: 'name', type: 'text', label: 'Name', align: 'center', minWidth: 80 }, - { name: 'created_at', type: 'timestamp', label: 'Created', align: 'center', minWidth: 80 }, - { name: 'url', type: 'text', label: 'URL', align: 'center', minWidth: 80 }, - { - name: 'render_javascript', - label: 'Renders JavaScript', - type: 'tag', - align: 'center', - minWidth: 80, - tag: { - true: 'primary', - false: 'info' - } - } - ], - actions: [ - { text: 'Edit', type: 'primary', icon: 'el-icon-edit', size: 'small', goto: '/dashboard' }, - { text: 'Delete', type: 'danger', icon: 'el-icon-delete', size: 'small', goto: '/dashboard' } - ] - } + private createForm = { + name: '', + baseUrl: '', + renderJS: false, + queryUrl: '', + selectorJson: {} + }; - get shops() { - return ShopsModule.shops; + private createRules = { + name: [{ trigger: 'blur', required: true, message: 'Please enter a name' }], + baseUrl: [{ trigger: 'blur', required: true, message: 'Please enter the base URL' }], + queryUrl: [{ trigger: 'blur', required: true, message: 'Please enter the query URL' }], + selectorJson: [{ trigger: 'blur', required: true, message: 'Please add the CSS selector code' }] + }; + + mounted() { + (this.$refs.name as Input).focus(); + (this.$refs.description as Input).focus(); } - created() { - ShopsModule.GetShops({}); + private handleCreate() { + (this.$refs.loginForm as ElForm).validate(async(valid: boolean) => { + if (valid) { + this.loading = true; + } else { + this.$message.error('validation failed'); + } + }); } } </script> <style lang="scss" scoped> - +.editor-container { + position: relative; + height: 100%; +} +.box-card { + max-width: 60rem; +} </style> diff --git a/client/src/views/users/EditUser.vue b/client/src/views/users/EditUser.vue index 700029f..0f9ec80 100644 --- a/client/src/views/users/EditUser.vue +++ b/client/src/views/users/EditUser.vue @@ -2,6 +2,8 @@ <div> Edit {{ user }} + + <el-button type="primary" @click="handleClick">Log</el-button> </div> </template> @@ -24,8 +26,15 @@ export default class extends Vue { status: '' }; + private userID = ''; + public cancel() { router.back(); } + + public handleClick() { + console.log(this.$route); + } + } </script> diff --git a/client/vue.config.js b/client/vue.config.js index c342beb..1e908a2 100644 --- a/client/vue.config.js +++ b/client/vue.config.js @@ -15,6 +15,7 @@ module.exports = { devServer: { port: devServerPort, open: true, + disableHostCheck: true, overlay: { warnings: false, errors: true diff --git a/imgs/1-login.png b/imgs/1-login.png new file mode 100644 index 0000000..62d8be4 Binary files /dev/null and b/imgs/1-login.png differ diff --git a/imgs/2-admin-dashboard.png b/imgs/2-admin-dashboard.png new file mode 100644 index 0000000..412dcee Binary files /dev/null and b/imgs/2-admin-dashboard.png differ diff --git a/imgs/3.0-user-CRUD.png b/imgs/3.0-user-CRUD.png new file mode 100644 index 0000000..4dbbf3b Binary files /dev/null and b/imgs/3.0-user-CRUD.png differ diff --git a/imgs/3.1-user-create.png b/imgs/3.1-user-create.png new file mode 100644 index 0000000..d35042f Binary files /dev/null and b/imgs/3.1-user-create.png differ diff --git a/imgs/4-user-profile.png b/imgs/4-user-profile.png new file mode 100644 index 0000000..08c003d Binary files /dev/null and b/imgs/4-user-profile.png differ diff --git a/imgs/5.0-shop-CRUD.png b/imgs/5.0-shop-CRUD.png new file mode 100644 index 0000000..4d1604f Binary files /dev/null and b/imgs/5.0-shop-CRUD.png differ diff --git a/imgs/5.1-shop-select.png b/imgs/5.1-shop-select.png new file mode 100644 index 0000000..ebd61e3 Binary files /dev/null and b/imgs/5.1-shop-select.png differ diff --git a/imgs/6-prices-display.png b/imgs/6-prices-display.png new file mode 100644 index 0000000..92be691 Binary files /dev/null and b/imgs/6-prices-display.png differ diff --git a/server/app/service/scraperservice.py b/server/app/service/scraperservice.py index 86d56d5..637786d 100644 --- a/server/app/service/scraperservice.py +++ b/server/app/service/scraperservice.py @@ -39,7 +39,7 @@ async def query_listings( # Use Splash to render our page if required if self._shop.render_javascript: - params = {"url": url, "timeout": "5", "images": 0} + params = {"url": url, "timeout": "7", "images": 0} base_url = "http://splash-browser:8050/render.html" html = await fetch_page(base_url, client=client, params=params) else: