aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEvgeny Zinoviev <me@ch1p.io>2021-02-16 12:54:42 +0300
committerEvgeny Zinoviev <me@ch1p.io>2021-02-16 12:54:42 +0300
commit94463e4926519fe2edc033cefac50d6690ed9a0a (patch)
treeb9d68d7353a584f48546f93603ea282be7b0ea3d
parentf01e27a00b9931b28ff8184751dedbe64db79ea2 (diff)
fixes, improvements. added text filter.
-rw-r--r--.gitignore4
-rw-r--r--acmespb.py (renamed from app/acmespb.py)13
-rw-r--r--app.py68
-rw-r--r--app/__init__.py91
-rw-r--r--app/static/app.js191
-rw-r--r--app/templates/index.html2
-rw-r--r--requirements.txt2
-rw-r--r--server.py7
-rw-r--r--static/app.js235
-rw-r--r--static/autocomplete.js (renamed from app/static/autocomplete.js)2
-rw-r--r--static/style.css (renamed from app/static/style.css)0
-rw-r--r--templates/index.html (renamed from app/templates/base.html)9
-rw-r--r--test.py (renamed from app/test.py)8
13 files changed, 322 insertions, 310 deletions
diff --git a/.gitignore b/.gitignore
index a0fd16e..6e7ec74 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,10 +5,6 @@ __pycache__/
venv/
instance/
-.pytest_cache/
-.coverage
-htmlcov/
-
dist/
build/
*.egg-info/
diff --git a/app/acmespb.py b/acmespb.py
index 5ebc41f..a56ed9a 100644
--- a/app/acmespb.py
+++ b/acmespb.py
@@ -102,10 +102,13 @@ def offers(query, target_url=None, page=1):
soup = BeautifulSoup(r.text, "html.parser")
p = soup.find("p", class_="red")
if p:
- total_matches = int(re.findall("([0-9]+)", p.string)[0])
- pages = math.ceil(total_matches / per_page)
+ try:
+ total_matches = int(re.findall("([0-9]+)", p.string)[0])
+ pages = math.ceil(total_matches / per_page)
+ except IndexError:
+ raise AcmeException(p.string)
- offers = []
+ offer_list = []
for trow in soup.find_all('div', class_='trow'):
if 'thead' in trow['class']:
continue
@@ -128,6 +131,6 @@ def offers(query, target_url=None, page=1):
acmepharm = AcmePharmacy(name=phname, address=address, phone=phone, geo=geo)
acmeoffer = AcmeOffer(name=name, country=country, price=price, pharmacy=acmepharm)
- offers.append(acmeoffer)
+ offer_list.append(acmeoffer)
- return target_url, pages, offers \ No newline at end of file
+ return target_url, pages, offer_list \ No newline at end of file
diff --git a/app.py b/app.py
new file mode 100644
index 0000000..2f6c4b8
--- /dev/null
+++ b/app.py
@@ -0,0 +1,68 @@
+import logging
+import traceback
+import acmespb
+
+from flask import Flask, render_template, jsonify, request
+
+
+app = Flask(__name__)
+app.config.from_mapping(SECRET_KEY='dev', JSON_AS_ASCII=False)
+
+logger = logging.getLogger('app')
+
+
+@app.after_request
+def add_header(r):
+ """
+ Add headers to both force latest IE rendering engine or Chrome Frame,
+ and also to cache the rendered page for 10 minutes.
+ """
+ r.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
+ r.headers["Pragma"] = "no-cache"
+ r.headers["Expires"] = "0"
+ r.headers['Cache-Control'] = 'public, max-age=0'
+ return r
+
+
+@app.route('/', methods=['GET'])
+def index():
+ return render_template('index.html')
+
+
+@app.route('/hints.ajax', methods=['GET'])
+def ajax_hints():
+ query = request.args.get('q') or ''
+ if len(query) < 3:
+ return jsonify(error="query is too short")
+
+ results = acmespb.search(query)
+ return jsonify(response=results)
+
+
+@app.route('/offers.ajax', methods=['GET'])
+def ajax_offers():
+ query = request.args.get('q') or ''
+ page = request.args.get('page') or 1
+ target_url = request.args.get('target_url') or ''
+
+ try:
+ if page == 1 or not target_url:
+ target_url, trade_names = acmespb.trade_names(query)
+ if trade_names:
+ return jsonify(tradeNames=trade_names)
+
+ target_url, pages, offers = acmespb.offers(query, page=page, target_url=target_url)
+ except Exception as e:
+ traceback.print_exc()
+ return jsonify(error=str(e))
+
+ return jsonify(
+ offers=[offer.as_dict() for offer in offers],
+ pages=pages
+ )
+
+ # TODO support empty results
+
+
+if __name__ == '__main__':
+ app.run(host='0.0.0.0', port=5000) \ No newline at end of file
diff --git a/app/__init__.py b/app/__init__.py
deleted file mode 100644
index 8ee21b9..0000000
--- a/app/__init__.py
+++ /dev/null
@@ -1,91 +0,0 @@
-import os
-import time
-
-from . import acmespb
-from flask import Flask, render_template
-from flask_socketio import SocketIO, emit
-
-socketio = SocketIO()
-
-
-def create_app(test_config=None):
- app = Flask(__name__, instance_relative_config=True)
- app.config.from_mapping(
- SECRET_KEY='dev',
- DATABASE=os.path.join(app.instance_path, 'app.sqlite'),
- )
-
- if test_config is None:
- # load the instance config, if it exists, when not testing
- app.config.from_pyfile('config.py', silent=True)
- else:
- # load the test config if passed in
- app.config.from_mapping(test_config)
-
- # ensure the instance folder exists
- try:
- os.makedirs(app.instance_path)
- except OSError:
- pass
-
- socketio.init_app(app)
-
- @app.route('/')
- def hello():
- return render_template('index.html')
-
- @socketio.on('get_hints')
- def handle_get_hints_event(q):
- print('[get_hints] id=%d, query=%s' % (q['id'], q['query']))
- if len(q['query']) < 3:
- response = {
- 'id': q['id'],
- 'error': "query is too short"
- }
- emit('hints', response)
- return
- results = acmespb.search(q['query'])
- response = {
- 'id': q['id'],
- 'response': results
- }
- emit('hints', response)
-
- @socketio.on('get_offers')
- def handle_get_offers_event(q):
- print('[get_offers] id=%d, query=%s' % (q['id'], q['query']))
- target_url, trade_names = acmespb.trade_names(q['query'])
- if trade_names:
- response = {
- 'id': q['id'],
- "response": trade_names
- }
- emit('hints', response)
- return
-
- page = 1
- pages = 0
- target_url = None
- while pages == 0 or page <= pages:
- target_url, pages, offers = acmespb.offers(q['query'], page=page, target_url=target_url)
- print("[%d] pages=%d, target_url=%s" % (page, pages, target_url))
- response = {
- 'id': q['id'],
- 'offers': [offer.as_dict() for offer in offers],
- 'page': page,
- 'pages': pages
- }
- emit('offers', response)
-
- time.sleep(0.5)
- page += 1
-
- response = {
- 'id': q['id'],
- 'end': True
- }
- emit('offers', response)
-
- # TODO empty response
-
- return app
diff --git a/app/static/app.js b/app/static/app.js
deleted file mode 100644
index faff329..0000000
--- a/app/static/app.js
+++ /dev/null
@@ -1,191 +0,0 @@
-class Search {
- constructor() {
- this.searchDebounced = _.debounce((query) => {
- if (query.length < 3)
- return;
- this.socket.emit('get_hints', {
- id: this.updateRequestId(),
- query
- });
- }, 150);
-
- let field = document.getElementById('queryInput');
- let btn = document.getElementById('querySubmit');
-
- this.autoComplete = new Autocomplete(field, {
- data: [],
- maximumItems: 10,
- onInput: (value) => {
- this.searchDebounced(value);
- },
- onSelectItem: ({label}) => {
- // console.log('selected:', label)
- },
- highlightClass: 'text-danger'
- });
-
- btn.addEventListener('click', this.onSubmit);
- field.addEventListener('keydown', this.onInputKeyDown);
-
- this.btn = btn;
- this.field = field;
-
- this.socket = io();
- this.socket.on('hints', this.onHints);
- this.socket.on('offers', this.onOffers)
- }
-
- updateRequestId() {
- this.requestId = requestId();
- return this.requestId;
- }
-
- onInputKeyDown = (e) => {
- if (e.keyCode === 10 || e.keyCode === 13)
- this.onSubmit();
- }
-
- onSubmit = (e) => {
- if (this.isLocked())
- return;
-
- this.lockButton('Загрузка...');
-
- gMaps.removeAllPoints();
- this.socket.emit('get_offers', {
- id: this.updateRequestId(),
- query: this.field.value
- });
- }
-
- onHints = (data) => {
- if (data.id !== this.requestId)
- return;
-
- this.unlockButton();
-
- if (data.error) {
- console.warn(data.error);
- return;
- }
-
- this.autoComplete.setData(data.response.map(item => {
- return {label: item, value: ''};
- }));
- this.autoComplete.renderIfNeeded();
- }
-
- onOffers = (data) => {
- if (data.id !== this.requestId)
- return;
-
- if (data.end) {
- this.unlockButton();
- return;
- } else {
- this.lockButton(data.pages > 1 ? `${data.page} из ${data.pages}` : null);
- }
-
- for (let offer of data.offers)
- gMaps.addOffer(offer);
- }
-
- isLocked() {
- return this.btn.classList.contains('disabled');
- }
-
- lockButton(text) {
- if (text !== null)
- this.btn.innerText = text;
- this.btn.classList.add('disabled');
- }
-
- unlockButton() {
- this.btn.classList.remove('disabled');
- this.btn.innerText = 'Поиск';
- }
-}
-
-
-class Maps {
- constructor() {
- /**
- * @type {ymaps.Map}
- */
- this.map = null;
- ymaps.ready(this.onInit);
-
- this.places = {};
- }
-
- onInit = () => {
- this.map = new ymaps.Map("mapContainer", {
- center: [59.94, 30.32],
- zoom: 11
- });
- this.map.controls.remove('searchControl');
- }
-
- addPoint({geo, offersRef, hint, pharmacyName, pharmacyAddress, pharmacyPhone}) {
- let mark = new ymaps.Placemark(geo, {
- hintContent: hint,
- }, {
- preset: 'islands#dotIcon',
- openEmptyBalloon: true,
- iconColor: '#3caa3c'
- });
- mark.events.add('balloonopen', e => {
- let lines = offersRef.map(offer => {
- return `${offer.name} (${offer.price} руб.)`
- });
- let html = `<b>${pharmacyName}</b><br>`;
- html += `${pharmacyAddress}<br>`;
- html += `тел: ${pharmacyPhone}<br><br>`;
- html += lines.join('<br>');
- mark.properties.set('balloonContent', html);
- });
- this.map.geoObjects.add(mark);
- return mark;
- }
-
- removeAllPoints() {
- this.map.geoObjects.removeAll();
- }
-
- addOffer(offer) {
- // console.log('[addOffer]', offer);
- let hash = offer.pharmacy.hash;
- if (hash in this.places)
- this.places[hash].offers.push(offer);
- else {
- this.places[hash] = {
- offers: [offer],
- };
- this.places[hash].mark = this.addPoint({
- geo: offer.pharmacy.geo,
- hint: offer.pharmacy.name,
- pharmacyName: offer.pharmacy.name,
- pharmacyAddress: offer.pharmacy.address,
- pharmacyPhone: offer.pharmacy.phone,
- offersRef: this.places[hash].offers
- });
- }
- }
-}
-
-
-function requestId() {
- return _.random(1, 99999999);
-}
-
-
-let gMaps, gSearch;
-
-window.addEventListener('DOMContentLoaded', function() {
- gSearch = new Search();
- gMaps = new Maps();
-
- // document.getElementById('test').addEventListener('click', () => {
- // gMaps.addTestPoint();
- // });
-}); \ No newline at end of file
diff --git a/app/templates/index.html b/app/templates/index.html
deleted file mode 100644
index 5cb6467..0000000
--- a/app/templates/index.html
+++ /dev/null
@@ -1,2 +0,0 @@
-{% extends "base.html" %}
-
diff --git a/requirements.txt b/requirements.txt
index 6bd1cda..4efc200 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,6 +1,4 @@
requests~=2.25.1
-eventlet
requests[socks]
beautifulsoup4~=4.9.3
Flask~=1.1.2
-Flask-SocketIO
diff --git a/server.py b/server.py
deleted file mode 100644
index e5e3347..0000000
--- a/server.py
+++ /dev/null
@@ -1,7 +0,0 @@
-#!/bin/env python
-from app import create_app, socketio
-
-app = create_app()
-
-if __name__ == '__main__':
- socketio.run(app, host='0.0.0.0')
diff --git a/static/app.js b/static/app.js
new file mode 100644
index 0000000..0f104fb
--- /dev/null
+++ b/static/app.js
@@ -0,0 +1,235 @@
+class Search {
+ constructor() {
+ this.searchDebounced = _.debounce((query) => {
+ if (query.length < 3)
+ return;
+
+ fetch(`/hints.ajax?q=${encodeURIComponent(query)}`)
+ .then(response => {
+ if (!response.ok)
+ throw new Error(`statusText is ${response.statusText}`);
+
+ return response.json();
+ })
+ .then(({response, error}) => {
+ this.unlockButton();
+
+ if (error)
+ throw new Error(error);
+
+ this.autoComplete.setData(response.map(item => {
+ return {label: item, value: ''};
+ }));
+ this.autoComplete.renderIfNeeded();
+ })
+ }, 150);
+
+ this._filterDebounced = _.debounce((e) => {
+ let filter = e.target;
+ globalMaps.setFilter(filter.value);
+ }, 500)
+
+ let field = document.getElementById('queryInput');
+ let btn = document.getElementById('querySubmit');
+ let filterField = document.getElementById('filterInput');
+
+ this.autoComplete = new Autocomplete(field, {
+ data: [],
+ maximumItems: 10,
+ onInput: (value) => {
+ this.searchDebounced(value);
+ },
+ onSelectItem: ({label}) => {
+ // console.log('selected:', label)
+ },
+ highlightClass: 'text-danger'
+ });
+
+ btn.addEventListener('click', this.onSubmit);
+ field.addEventListener('keydown', this.onInputKeyDown);
+ filterField.addEventListener('input', this._filterDebounced);
+
+ this.btn = btn;
+ this.field = field;
+ }
+
+ onInputKeyDown = (e) => {
+ if (e.keyCode === 10 || e.keyCode === 13)
+ this.onSubmit();
+ }
+
+ getOffers(query, page) {
+ fetch(`/offers.ajax?q=${encodeURIComponent(this.field.value)}&page=${page}`)
+ .then(response => {
+ if (!response.ok)
+ throw new Error(`statusText is ${response.statusText}`);
+
+ return response.json();
+ })
+ .then(({error, tradeNames, end, offers, pages}) => {
+ if (error)
+ throw new Error(error);
+
+ if (tradeNames) {
+ this.autoComplete.setData(tradeNames.map(item => {
+ return {label: item, value: ''};
+ }));
+ this.autoComplete.renderIfNeeded();
+ return this.unlockButton();
+ }
+
+ for (let offer of offers)
+ globalMaps.addOffer(offer);
+
+ if (page >= pages) {
+ return this.unlockButton();
+ } else {
+ this.lockButton(pages > 1 ? `${page} из ${pages}` : null);
+ setTimeout(() => {
+ this.getOffers(query, page + 1);
+ }, 1000)
+ }
+ })
+ .catch((error) => {
+ console.error(error);
+ alert(error);
+ this.unlockButton();
+ })
+ }
+
+ onSubmit = (e) => {
+ if (this.isLocked())
+ return;
+
+ this.lockButton('Загрузка...');
+
+ globalMaps.removeAllPoints();
+
+ this.getOffers(this.field.value, 1);
+ }
+
+ isLocked() {
+ return this.btn.classList.contains('disabled');
+ }
+
+ lockButton(text) {
+ if (text !== null)
+ this.btn.innerText = text;
+ this.btn.classList.add('disabled');
+ }
+
+ unlockButton() {
+ this.btn.classList.remove('disabled');
+ this.btn.innerText = 'Поиск';
+ }
+}
+
+
+class Maps {
+ constructor() {
+ /**
+ * @type {ymaps.Map}
+ */
+ this.map = null;
+ ymaps.ready(this.onInit);
+
+ this.filter = null;
+ this.places = {};
+ }
+
+ onInit = () => {
+ this.map = new ymaps.Map("mapContainer", {
+ center: [59.94, 30.32],
+ zoom: 11
+ });
+ this.map.controls.remove('searchControl');
+ }
+
+ addPoint({geo, offersRef, hint, pharmacyName, pharmacyAddress, pharmacyPhone}) {
+ let mark = new ymaps.Placemark(geo, {
+ hintContent: hint,
+ }, {
+ preset: 'islands#dotIcon',
+ openEmptyBalloon: true,
+ iconColor: '#3caa3c'
+ });
+ mark.events.add('balloonopen', e => {
+ let lines = offersRef.map(offer => {
+ return `${offer.name} (${offer.price} руб.)`
+ });
+ let html = `<b>${pharmacyName}</b><br>`;
+ html += `${pharmacyAddress}<br>`;
+ html += `тел: ${pharmacyPhone}<br><br>`;
+ html += lines.join('<br>');
+ mark.properties.set('balloonContent', html);
+ });
+ this.map.geoObjects.add(mark);
+ return mark;
+ }
+
+ removeAllPoints() {
+ this.map.geoObjects.removeAll();
+ }
+
+ addOffer(offer) {
+ // console.log('[addOffer]', offer);
+ let hash = offer.pharmacy.hash;
+ if (hash in this.places)
+ this.places[hash].offers.push(offer);
+ else
+ this.places[hash] = {offers: [offer]};
+
+ if (!this.places[hash].mark && this.isAllowed(offer.name))
+ this.showPlaceOnMap(hash, offer);
+ }
+
+ showPlaceOnMap(hash, offer) {
+ this.places[hash].mark = this.addPoint({
+ geo: offer.pharmacy.geo,
+ hint: offer.pharmacy.name,
+ pharmacyName: offer.pharmacy.name,
+ pharmacyAddress: offer.pharmacy.address,
+ pharmacyPhone: offer.pharmacy.phone,
+ offersRef: this.places[hash].offers
+ });
+ }
+
+ hidePlaceFromMap(hash) {
+ if (this.places[hash].mark) {
+ this.map.geoObjects.remove(this.places[hash].mark);
+ delete this.places[hash].mark;
+ }
+ }
+
+ setFilter(filter) {
+ if (!filter)
+ filter = null;
+ this.filter = filter;
+
+ for (let hash in this.places) {
+ if (!this.places.hasOwnProperty(hash))
+ continue;
+
+ let pl = this.places[hash];
+ let ok = pl.offers.filter(o => this.isAllowed(o.name))
+
+ if (pl.mark && !ok.length)
+ this.hidePlaceFromMap(hash);
+
+ else if (!pl.mark && ok.length)
+ this.showPlaceOnMap(hash, ok[0]);
+ }
+ }
+
+ isAllowed(productName) {
+ return this.filter === null || productName.indexOf(this.filter) !== -1;
+ }
+}
+
+let globalMaps = null;
+let globalSearch = null;
+
+window.addEventListener('DOMContentLoaded', function() {
+ globalSearch = new Search();
+ globalMaps = new Maps();
+}); \ No newline at end of file
diff --git a/app/static/autocomplete.js b/static/autocomplete.js
index c336123..90629a2 100644
--- a/app/static/autocomplete.js
+++ b/static/autocomplete.js
@@ -96,7 +96,7 @@ class Autocomplete {
this.field.value = e.target.innerText;
if (this.options.onSelectItem)
this.options.onSelectItem({
- value: e.target.value,
+ value: e.target.dataset.value,
label: e.target.innerText,
});
this.dropdown.hide();
diff --git a/app/static/style.css b/static/style.css
index e69de29..e69de29 100644
--- a/app/static/style.css
+++ b/static/style.css
diff --git a/app/templates/base.html b/templates/index.html
index 8bc8cc7..453a962 100644
--- a/app/templates/base.html
+++ b/templates/index.html
@@ -12,10 +12,10 @@
<script src="https://api-maps.yandex.ru/2.1/?apikey=ce936229-3ef4-41b1-96c0-270bcf8ff341&lang=ru_RU" type="text/javascript"></script>
- <script src="{{ url_for('static', filename='autocomplete.js') }}"></script>
- <script src="{{ url_for('static', filename='app.js') }}"></script>
+ <script src="{{ url_for('static', filename='autocomplete.js') }}?1"></script>
+ <script src="{{ url_for('static', filename='app.js') }}?4"></script>
- <title>{% block title %}{% endblock %}</title>
+ <title>ACMESPB здорового человека</title>
</head>
<body class="h-100 mh-100">
<div class="container h-100 pt-4 pb-4">
@@ -23,7 +23,8 @@
<div>
<form class="mb-4" onsubmit="return false">
<div class="input-group">
- <input type="text" class="form-control" id="queryInput" placeholder="Введите название препарата" autocomplete="off">
+ <input type="text" class="col-sm-7 form-control" id="queryInput" placeholder="Введите название препарата" autocomplete="off" style="flex: 2 1 auto;">
+ <input type="text" class="form-control" id="filterInput" placeholder="Фильтр" autocomplete="off">
<button type="submit" class="btn btn-outline-primary" id="querySubmit">Поиск</button>
</div>
</form>
diff --git a/app/test.py b/test.py
index a0481e2..5acaacc 100644
--- a/app/test.py
+++ b/test.py
@@ -1,14 +1,16 @@
import acmespb
-import sys
-from pprint import pprint
if __name__ == "__main__":
+ q_empty = "Волекам"
+ q_many = "Верошпирон"
+
#pprint(acmespb.trade_names("Марена красильная корневища и корни"))
+
page = 1
pages = 0
target_url = None
while pages == 0 or page <= pages:
- target_url, pages, offers = acmespb.offers("Верошпирон", page=page, target_url=target_url)
+ target_url, pages, offers = acmespb.offers(q_empty, page=page, target_url=target_url)
print("[%d] pages=%d, target_url=%s" % (page, pages, target_url))
for offer in offers:
print(offer.as_dict())