├── fe ├── robots.txt ├── arrow.png ├── bus_round.png ├── favicon.ico ├── geolocation.png ├── bus_round_lf.png ├── img │ ├── bus_round.png │ ├── bus_round_lf.png │ ├── bus_round_wait.png │ ├── bus_round_lf_wait.png │ ├── bus_round_long_wait.png │ └── bus_round_lf_long_wait.png ├── auto-complete.css ├── businfo.html ├── map.html ├── map_only.html ├── index.html ├── feedback.html ├── map.css ├── promises.min.js ├── busstops.html ├── busroutes.html ├── auto-complete.min.js ├── fetch.min.js ├── busroutes.js ├── main.js ├── busstops.js └── map.js ├── CNAME ├── Procfile ├── runtime.txt ├── .env.example ├── test_data.7z ├── libs ├── libfbclient.so ├── libfbclient.so.2 ├── libtommath.so.1 ├── libfbclient.so.3.0.5 ├── libspatialindex.so.4 ├── libspatialindex_c.so ├── libtommath.so.1.2.0 └── libspatialindex_c.so.4 ├── tox.ini ├── .gitignore ├── docs └── index.html ├── requirements.txt ├── models.py ├── settings.py.tmpl ├── README.md ├── save_test_data.py ├── alembic └── versions │ └── 97bd8c40e2d5_init.py ├── db.py ├── test_helpers.py ├── bus_routes_codd.json ├── fotobus_scrapper.py ├── alembic_dev.ini ├── index.html ├── abuse_checker.py ├── main.py ├── test_tracking.py ├── tracking.py ├── data_types.py ├── helpers.py ├── test_cds.py ├── data_processors.py ├── website.py ├── data_providers.py └── tgbot.py /fe/robots.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | test.vrntrans.ru -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: python main.py -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.8.12 -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DUMP_DB_REQUESTS = True -------------------------------------------------------------------------------- /fe/arrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrntrans/vrnbus/HEAD/fe/arrow.png -------------------------------------------------------------------------------- /test_data.7z: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrntrans/vrnbus/HEAD/test_data.7z -------------------------------------------------------------------------------- /fe/bus_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrntrans/vrnbus/HEAD/fe/bus_round.png -------------------------------------------------------------------------------- /fe/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrntrans/vrnbus/HEAD/fe/favicon.ico -------------------------------------------------------------------------------- /fe/geolocation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrntrans/vrnbus/HEAD/fe/geolocation.png -------------------------------------------------------------------------------- /fe/bus_round_lf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrntrans/vrnbus/HEAD/fe/bus_round_lf.png -------------------------------------------------------------------------------- /fe/img/bus_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrntrans/vrnbus/HEAD/fe/img/bus_round.png -------------------------------------------------------------------------------- /libs/libfbclient.so: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrntrans/vrnbus/HEAD/libs/libfbclient.so -------------------------------------------------------------------------------- /libs/libfbclient.so.2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrntrans/vrnbus/HEAD/libs/libfbclient.so.2 -------------------------------------------------------------------------------- /libs/libtommath.so.1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrntrans/vrnbus/HEAD/libs/libtommath.so.1 -------------------------------------------------------------------------------- /fe/img/bus_round_lf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrntrans/vrnbus/HEAD/fe/img/bus_round_lf.png -------------------------------------------------------------------------------- /fe/img/bus_round_wait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrntrans/vrnbus/HEAD/fe/img/bus_round_wait.png -------------------------------------------------------------------------------- /libs/libfbclient.so.3.0.5: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrntrans/vrnbus/HEAD/libs/libfbclient.so.3.0.5 -------------------------------------------------------------------------------- /libs/libspatialindex.so.4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrntrans/vrnbus/HEAD/libs/libspatialindex.so.4 -------------------------------------------------------------------------------- /libs/libspatialindex_c.so: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrntrans/vrnbus/HEAD/libs/libspatialindex_c.so -------------------------------------------------------------------------------- /libs/libtommath.so.1.2.0: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrntrans/vrnbus/HEAD/libs/libtommath.so.1.2.0 -------------------------------------------------------------------------------- /fe/img/bus_round_lf_wait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrntrans/vrnbus/HEAD/fe/img/bus_round_lf_wait.png -------------------------------------------------------------------------------- /libs/libspatialindex_c.so.4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrntrans/vrnbus/HEAD/libs/libspatialindex_c.so.4 -------------------------------------------------------------------------------- /fe/img/bus_round_long_wait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrntrans/vrnbus/HEAD/fe/img/bus_round_long_wait.png -------------------------------------------------------------------------------- /fe/img/bus_round_lf_long_wait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrntrans/vrnbus/HEAD/fe/img/bus_round_lf_long_wait.png -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = 3 | .git, 4 | __pycache__, 5 | fe, 6 | logs 7 | max-complexity = 8 8 | max-line-length = 110 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /logs/* 2 | /__pycache__/* 3 | /settings.py 4 | /test_data/* 5 | /.idea/* 6 | /.ssh/* 7 | *.dll 8 | *.dll 9 | /venv/* 10 | /.env 11 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |

https://vrnbus.herokuapp.com

7 | 8 | 9 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | python-telegram-bot==11.1.0 2 | cachetools==2.0.1 3 | tornado==6.1 4 | pytz==2017.3 5 | fdb==2.0.1 6 | beautifulsoup4==4.8.0 7 | freezegun~=1.1.0 8 | APScheduler==3.5.3 9 | Rtree==0.8.3 10 | requests==2.22.0 11 | psycopg2==2.8.6 12 | psycopg2-binary==2.8.6 13 | sqlalchemy==1.3.23 14 | python-dotenv==0.15.0 15 | firebird-driver~=1.3.4 16 | -------------------------------------------------------------------------------- /models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, String 2 | 3 | from db import Base 4 | from sqlalchemy import Column, String 5 | 6 | from db import Base 7 | 8 | 9 | class RouteEdges(Base): 10 | __tablename__ = 'route_edges' 11 | 12 | edge_key = Column(String(100), nullable=False, unique=True, index=True, primary_key=True) 13 | points = Column(String, nullable=False, default='') 14 | 15 | def __str__(self): 16 | return f"User({self.first_name} {self.last_name} | {self.email})" 17 | 18 | -------------------------------------------------------------------------------- /settings.py.tmpl: -------------------------------------------------------------------------------- 1 | CDS_HOST = '192.168.0.1' 2 | CDS_DB_PATH = r'C:\DB\PROJECTS.FDB' 3 | CDS_DB_PATH = r'C:\DB\PROJECTS.FDB' 4 | CDS_DB_DATA_PATH = r'C:\DB\DATA.FDB' 5 | CDS_DB_PROJECTS_PATH = r'C:\DB\PROJECTS.FDB' 6 | CDS_USER = 'SYSDBA' 7 | CDS_PASS = 'masterkey' 8 | VRNBUSBOT_TOKEN = '' 9 | PING_HOST = 'http://localhost:8080' 10 | USERS_TO_INFORM = "" 11 | VRNBUSBOT_TOKEN = '' 12 | 13 | LOAD_TEST_DATA = True 14 | FULL_ACCESS_KEY = "" 15 | DB_LINK = 'postgresql://vrnbus:vrnbus@127.0.0.1/vrnbus' 16 | 17 | -------------------------------------------------------------------------------- /fe/auto-complete.css: -------------------------------------------------------------------------------- 1 | .autocomplete-suggestions { 2 | text-align: left; cursor: default; border: 1px solid #ccc; border-top: 0; background: #fff; box-shadow: -1px 1px 3px rgba(0,0,0,.1); 3 | 4 | /* core styles should not be changed */ 5 | position: absolute; display: none; z-index: 9999; max-height: 254px; overflow: hidden; overflow-y: auto; box-sizing: border-box; 6 | } 7 | .autocomplete-suggestion { position: relative; padding: 0 .6em; line-height: 23px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 1.02em; color: #333; } 8 | .autocomplete-suggestion b { font-weight: normal; color: #1f8dd6; } 9 | .autocomplete-suggestion.selected { background: #f0f0f0; } 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vrnbus 2 | Прогноз прибытия автобусов в Воронеже. 3 | Веб-версия: https://vrnbus.herokuapp.com 4 | Телеграм-бот: https://t.me/vrnbusbot 5 | 6 | # Техническое 7 | 8 | Front-end: чистый JavaScript и поддержка fetch/promises для старых браузеров 9 | 10 | Back-end: Python 3.6 (cachetools, fdb, python-telegram-bot, pytz, tornado) 11 | 12 | # Видео о проекте 13 | https://www.youtube.com/watch?v=1OtHwGqSL04 14 | 15 | # Установка 16 | * Установить Python 3.6 или новее 17 | * `pip install -r requirements.txt` 18 | * Распаковать `test_data.7z` в каталог `test_data` 19 | * Создать Телеграм-бота для тестов с помощью бота 20 | [@BotFather](https://t.me/BotFather) и получить его токен 21 | * Указать токен в `settings.py` или в переменной окружения VRNBUSBOT_TOKEN 22 | * Запустить `main.py` и открыть [http://localhost:8080](http://localhost:8080). -------------------------------------------------------------------------------- /save_test_data.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import datetime 3 | import json 4 | import logging 5 | import time 6 | from pathlib import Path 7 | 8 | from data_providers import CdsDBDataProvider 9 | from helpers import CustomJsonEncoder 10 | 11 | 12 | def save_test_data(file_numbers=500, sleep_time=30): 13 | logging.basicConfig(format='%(asctime)s - %(levelname)s [%(filename)s:%(lineno)s %(funcName)20s] %(message)s', 14 | level=logging.INFO, 15 | handlers=[logging.StreamHandler()]) 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | if not Path('test_data').is_dir(): 20 | Path('test_data').mkdir() 21 | 22 | cds = CdsDBDataProvider(logger) 23 | logger.info("Start") 24 | for i in range(file_numbers): 25 | codd_data_db = cds.load_all_cds_buses() 26 | now = datetime.datetime.now() 27 | with open(f'test_data/codd_data_db{now:%y_%m_%d_%H_%M_%S}.json', 'wb') as f: 28 | json.dump(codd_data_db, codecs.getwriter('utf-8')(f), ensure_ascii=False, indent=1, cls=CustomJsonEncoder) 29 | time.sleep(sleep_time) 30 | 31 | logger.info("Stop") 32 | 33 | 34 | if __name__ == '__main__': 35 | save_test_data() 36 | -------------------------------------------------------------------------------- /alembic/versions/97bd8c40e2d5_init.py: -------------------------------------------------------------------------------- 1 | """Init 2 | 3 | Revision ID: 97bd8c40e2d5 4 | Revises: 5 | Create Date: 2019-10-07 23:39:56.524903 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = '97bd8c40e2d5' 13 | down_revision = None 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.create_table('route_edges', 21 | sa.Column('created_at', sa.DateTime(), nullable=True), 22 | sa.Column('updated_at', sa.DateTime(), nullable=True), 23 | sa.Column('edge_key', sa.String(length=100), nullable=False), 24 | sa.Column('points', sa.String(), nullable=False), 25 | sa.PrimaryKeyConstraint('edge_key') 26 | ) 27 | op.create_index(op.f('ix_route_edges_created_at'), 'route_edges', ['created_at'], unique=False) 28 | op.create_index(op.f('ix_route_edges_edge_key'), 'route_edges', ['edge_key'], unique=True) 29 | op.create_index(op.f('ix_route_edges_updated_at'), 'route_edges', ['updated_at'], unique=False) 30 | # ### end Alembic commands ### 31 | 32 | 33 | def downgrade(): 34 | # ### commands auto generated by Alembic - please adjust! ### 35 | op.drop_index(op.f('ix_route_edges_updated_at'), table_name='route_edges') 36 | op.drop_index(op.f('ix_route_edges_edge_key'), table_name='route_edges') 37 | op.drop_index(op.f('ix_route_edges_created_at'), table_name='route_edges') 38 | op.drop_table('route_edges') 39 | # ### end Alembic commands ### 40 | -------------------------------------------------------------------------------- /db.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from contextlib import contextmanager 4 | from datetime import datetime 5 | 6 | from sqlalchemy import create_engine, Column, DateTime 7 | # from sqlalchemy.ext.declarative import declarative_base 8 | from sqlalchemy.ext.declarative import as_declarative 9 | from sqlalchemy.orm import sessionmaker 10 | 11 | logger = logging.getLogger("db") 12 | try: 13 | import settings 14 | 15 | DB_LINK = settings.DB_LINK 16 | except ImportError: 17 | env = os.environ 18 | DB_LINK = env.get('DB_LINK') 19 | 20 | engine = create_engine(DB_LINK) 21 | 22 | 23 | def add_row(entity): 24 | session = Session() 25 | session.add(entity) 26 | session.commit() 27 | 28 | 29 | @as_declarative() 30 | class Base: 31 | created_at = Column(DateTime, default=datetime.utcnow, index=True) 32 | updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, index=True) 33 | 34 | 35 | def __repr__(self): 36 | return str(self) 37 | 38 | def __str__(self): 39 | return f'{self.__class__.__name__} {self.created_at} {self.updated_at}' 40 | 41 | Session = sessionmaker(bind=engine) 42 | 43 | 44 | @contextmanager 45 | def session_scope(scope_info=None): 46 | """Provide a transactional scope around a series of operations.""" 47 | session = Session() 48 | from sqlalchemy.orm.exc import NoResultFound 49 | try: 50 | yield session 51 | session.commit() 52 | except NoResultFound: 53 | logger.exception(scope_info) 54 | session.rollback() 55 | except Exception as ex: 56 | logger.exception(scope_info) 57 | session.rollback() 58 | raise 59 | finally: 60 | session.close() -------------------------------------------------------------------------------- /test_helpers.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import helpers 4 | 5 | 6 | class TestFuzzySearch(unittest.TestCase): 7 | def test_different_cases(self): 8 | f = helpers.fuzzy_search_advanced 9 | cases = [ 10 | ('кирова в центр', 'ул. Кирова (в центр)', True), 11 | ('кирова', 'ул. Кирова (в центр)', True), 12 | ('кирова', 'ДК им. Кирова (ул. Героев Сибиряков из центра)', True), 13 | ('кирова в центр', 'ДК им. Кирова (ул. Героев Сибиряков из центра)', False), 14 | ('кирова в центр', 'ДК им. Кирова (ул. Героев Сибиряков из центра)', False), 15 | ('дк кир лен', 'ДК им. Кирова (Ленинский пр-т в сторону ул. Димитрова)', True), 16 | ('дк кир лен', 'ДК им. Кирова (Ленинский проспект в сторону Машмета)', True), 17 | ('брно', 'Мебель Черноземья (в центр)', False), 18 | ('автовокзал в', 'автовокзал (в центр)', True), 19 | ('автовокзал в', 'Центральный автовокзал (в центр)', True), 20 | ] 21 | 22 | for (needle, haystack, result) in cases: 23 | with self.subTest(f'{needle}, {haystack}, {result}'): 24 | self.assertEqual(f(needle, haystack), result) 25 | 26 | 27 | class TestGeoFunction(unittest.TestCase): 28 | def test_azimuth(self): 29 | f = helpers.azimuth 30 | cases = [ 31 | ((24.323810, 1.368795, 39.169720, 51.652228), 12), 32 | ((241.323810, 1.468795, 39.182616, 51.697372), 16), 33 | ] 34 | 35 | for (params, result) in cases: 36 | with self.subTest(f'{params}, {result}'): 37 | self.assertEqual(f(*params), result) 38 | 39 | 40 | if __name__ == '__main__': 41 | unittest.main() 42 | -------------------------------------------------------------------------------- /fe/businfo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | vrnbus (beta) | Поиск по автобусам Воронежа 9 | 10 | 11 | 17 | 18 |
19 | 20 | 23 | 24 |
25 |
26 |
27 | 28 |
29 | 30 |
31 |
32 | 33 |
34 |
35 |
36 |
37 |
38 | 39 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /bus_routes_codd.json: -------------------------------------------------------------------------------- 1 | { 2 | "104": 53, 3 | "105": 63, 4 | "108А": 73, 5 | "10А": 67, 6 | "11": 68, 7 | "113": 46, 8 | "113КТ": 121, 9 | "113КШ": 131, 10 | "115": 115, 11 | "120": 15, 12 | "120В": 39, 13 | "122": 13, 14 | "125": 62, 15 | "125А": 111, 16 | "13": 77, 17 | "14В": 136, 18 | "15": 92, 19 | "15А": 78, 20 | "16В": 123, 21 | "17": 55, 22 | "18": 74, 23 | "1КВ": 30, 24 | "1КС": 29, 25 | "20": 82, 26 | "20Б": 113, 27 | "20М": 88, 28 | "22": 101, 29 | "23К": 18, 30 | "25А": 52, 31 | "26А": 69, 32 | "27": 9, 33 | "3": 49, 34 | "33К": 19, 35 | "34": 80, 36 | "37": 81, 37 | "37А": 85, 38 | "39": 59, 39 | "3В": 75, 40 | "41": 34, 41 | "42": 187, 42 | "43": 163, 43 | "47": 45, 44 | "48": 133, 45 | "49": 22, 46 | "49А": 104, 47 | "49Б": 24, 48 | "49М": 144, 49 | "5": 48, 50 | "50": 126, 51 | "52": 1, 52 | "54": 66, 53 | "57В": 12, 54 | "58В": 16, 55 | "59": 134, 56 | "59А": 91, 57 | "59АС": 106, 58 | "5А": 8, 59 | "6": 100, 60 | "60Б": 35, 61 | "61": 129, 62 | "62": 61, 63 | "64": 57, 64 | "64А": 200, 65 | "65": 76, 66 | "65А": 97, 67 | "66": 58, 68 | "67А": 33, 69 | "68": 54, 70 | "68А": 198, 71 | "68Т": 118, 72 | "69Т": 4, 73 | "6М": 60, 74 | "70А": 50, 75 | "70М": 21, 76 | "75": 72, 77 | "76": 31, 78 | "77К": 44, 79 | "78А": 108, 80 | "79": 65, 81 | "8": 32, 82 | "80": 2, 83 | "81": 70, 84 | "84": 56, 85 | "87": 145, 86 | "88": 23, 87 | "88А": 83, 88 | "90": 64, 89 | "91": 109, 90 | "93": 130, 91 | "98": 140, 92 | "9КА": 38, 93 | "9КС": 51, 94 | "Тр.11": 41, 95 | "Тр.17": 40, 96 | "Тр.7": 6, 97 | "Тр.8": 42 98 | } -------------------------------------------------------------------------------- /fotobus_scrapper.py: -------------------------------------------------------------------------------- 1 | import re 2 | import time 3 | from typing import List 4 | 5 | import requests 6 | from bs4 import BeautifulSoup 7 | 8 | fb_name_re = re.compile(r"(\D+)(\d+)(\D+)(\d+)") 9 | fb_name_re_2 = re.compile(r"(\D+)(\d+)(\d{2})") 10 | 11 | def get_name_with_spaces(bus_name): 12 | match = fb_name_re.match(bus_name) 13 | if not match: 14 | match = fb_name_re_2.match(bus_name) 15 | if not match: 16 | return 17 | 18 | return " ".join(match.groups()) 19 | 20 | def get_url(bus_name): 21 | name_w_spaces = get_name_with_spaces(bus_name) 22 | if not name_w_spaces: 23 | return 24 | return "http://fotobus.msk.ru/ajax2.php?action=index-qsearch&cid=0&type=1&num=" + name_w_spaces 25 | 26 | def get_bus_search_page(bus_name): 27 | url = get_url(bus_name) 28 | if not url: 29 | return 30 | 31 | result = requests.get(url, allow_redirects=True) 32 | print(result.url) 33 | return result.content 34 | 35 | def get_fb_links(content): 36 | soup = BeautifulSoup(content, features="html.parser") 37 | anchors = soup.find_all("a") 38 | hrefs = {a.get('href').split('#')[0] for a in anchors} 39 | print(hrefs) 40 | return [f"http://fotobus.msk.ru{h}" for h in hrefs if not h.startswith('http')] 41 | 42 | 43 | def fb_links(bus_name): 44 | url = get_url(bus_name) 45 | if not url: 46 | return [] 47 | content = get_bus_search_page(bus_name) 48 | links = get_fb_links(content) 49 | return links or [url, ] 50 | 51 | 52 | 53 | if __name__ == '__main__': 54 | start = time.time() 55 | content = get_bus_search_page("Е312УС36") 56 | result = get_fb_links(content) 57 | print(result) 58 | print(time.time() - start) 59 | content = get_bus_search_page("ВВ37336") 60 | result = get_fb_links(content) 61 | print(result) 62 | print(time.time() - start) 63 | print(fb_links("ВВ37336")) -------------------------------------------------------------------------------- /fe/map.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | vrnbus (beta) | Карта автобусов Воронежа 7 | 8 | 9 | 10 | 11 | 12 | 18 |
19 | 20 | 23 |
24 |
25 |
26 | 27 |
28 | 29 |
30 |
31 |
32 |
33 | 34 | 35 |
36 |
37 |
38 |
39 |
40 |
41 | 42 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /alembic_dev.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = alembic 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # timezone to use when rendering the date 11 | # within the migration file as well as the filename. 12 | # string value is passed to dateutil.tz.gettz() 13 | # leave blank for localtime 14 | # timezone = 15 | 16 | # max length of characters to apply to the 17 | # "slug" field 18 | #truncate_slug_length = 40 19 | 20 | # set to 'true' to run the environment during 21 | # the 'revision' command, regardless of autogenerate 22 | # revision_environment = false 23 | 24 | # set to 'true' to allow .pyc and .pyo files without 25 | # a source .py file to be detected as revisions in the 26 | # versions/ directory 27 | # sourceless = false 28 | 29 | # version location specification; this defaults 30 | # to alembic/versions. When using multiple version 31 | # directories, initial revisions must be specified with --version-path 32 | # version_locations = %(here)s/bar %(here)s/bat alembic/versions 33 | 34 | # the output encoding used when revision files 35 | # are written from script.py.mako 36 | # output_encoding = utf-8 37 | 38 | # sqlalchemy.url = driver://user:pass@localhost/dbname 39 | sqlalchemy.url = postgresql://vrnbus:vrnbus@127.0.0.1/vrnbus 40 | 41 | 42 | # Logging configuration 43 | [loggers] 44 | keys = root,sqlalchemy,alembic 45 | 46 | [handlers] 47 | keys = console 48 | 49 | [formatters] 50 | keys = generic 51 | 52 | [logger_root] 53 | level = WARN 54 | handlers = console 55 | qualname = 56 | 57 | [logger_sqlalchemy] 58 | level = WARN 59 | handlers = 60 | qualname = sqlalchemy.engine 61 | 62 | [logger_alembic] 63 | level = INFO 64 | handlers = 65 | qualname = alembic 66 | 67 | [handler_console] 68 | class = StreamHandler 69 | args = (sys.stderr,) 70 | level = NOTSET 71 | formatter = generic 72 | 73 | [formatter_generic] 74 | format = %(levelname)-5.5s [%(name)s] %(message)s 75 | datefmt = %H:%M:%S 76 | -------------------------------------------------------------------------------- /fe/map_only.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | vrnbus (beta) | Карта автобусов Воронежа 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 23 |
24 |
25 |
26 | 27 |
28 | 29 |
30 |
31 |
32 |
33 | 34 | 35 |
36 |
37 |
38 |
39 |
40 |
41 | 42 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 | Прогноз прибытия автобусов в Воронеже
8 | Сайт: vrnbus.herokuapp.com
9 | Telegram-бот: @vrnbusbot
10 | Приложения: Android, 11 | iOS
12 |
13 | Город и транспорт 14 |
15 | Telegram: канал @vrntrans, чат @vrntranschat
16 | ВКонтакте: @vrntrans
17 | Facebook: @vrntrans
18 |
19 | Техническое
20 | GitHub проекта: vrntrans/vrnbus
21 | Front-end: чистый JavaScript и поддержка fetch/promises для старых браузеров
22 | Back-end: Python 3.6 (cachetools, fdb, python-telegram-bot, pytz, tornado)
23 | Доклад о проекте: https://www.youtube.com/watch?v=1OtHwGqSL04
24 |
25 | Предложения и пожелания
26 | Пожаловаться через GitHub
27 | Пожаловаться через почту
28 |
29 | Дополнительные возможности (бета беты)
30 | Статистика: /stats.html
31 | Все остановки Воронежа: /busstops.html
32 | Все маршруты Воронежа: /busroutes.html
33 |
34 | Благодарности
35 | Сделано совместно с МБУ ЦОДД
36 |
37 | 38 |

https://vrnbus.herokuapp.com

39 | 40 | 41 | -------------------------------------------------------------------------------- /abuse_checker.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | from collections import defaultdict, deque 4 | from typing import List 5 | 6 | from data_types import AbuseRule 7 | 8 | BLACK_LIST = os.environ.get('BLACK_LIST', '') 9 | 10 | def last_time(delta: datetime.timedelta): 11 | return datetime.datetime.now() - delta 12 | 13 | 14 | class AbuseChecker: 15 | def __init__(self, logger, rules: List[AbuseRule]): 16 | self.logger = logger 17 | self.events = {} 18 | self.default_rule = AbuseRule(0, 100, datetime.timedelta(minutes=60)) 19 | self.rules = {v.event: v for v in rules} 20 | for rule in rules: 21 | self.events[rule.event] = defaultdict(lambda: deque(maxlen=rule.count)) 22 | 23 | def reset_stats(self, user_id): 24 | self.events[user_id].clear() 25 | 26 | def prepare_dict(self, event): 27 | if event in self.events: 28 | return 29 | rule = self.rules.get(event) 30 | if rule: 31 | self.logger.error(f"There is no prepared dict for {event}") 32 | self.events[event] = defaultdict(lambda: deque(maxlen=rule.count)) 33 | return 34 | 35 | self.logger.error(f"There is no rule for {event}") 36 | self.events[event] = defaultdict(lambda: deque(maxlen=self.default_rule.count)) 37 | 38 | def check_time(self): 39 | now = datetime.datetime.now() 40 | return now.hour >= 20 or now.hour <= 7 41 | 42 | def check_user(self, event, user_id): 43 | if user_id in BLACK_LIST: 44 | return False 45 | 46 | if self.check_time(): 47 | return True 48 | 49 | self.prepare_dict(event) 50 | user_events = self.events[event][user_id] 51 | rule = self.rules.get(event, self.default_rule) 52 | if len(user_events) < rule.count: 53 | return True 54 | 55 | min_time = min(user_events) 56 | if min_time < last_time(rule.delta): 57 | return True 58 | 59 | return False 60 | 61 | def add_user_event(self, event, user_id): 62 | self.prepare_dict(event) 63 | self.events[event][user_id].append(datetime.datetime.now()) 64 | return self.check_user(event, user_id) 65 | -------------------------------------------------------------------------------- /fe/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | vrnbus (beta) | Прибытие на остановки 10 | 11 | 12 | 18 |
19 | 20 | 21 |
(Нужно разрешить сайту запрашивать геолокацию)

22 |
23 |
24 | 25 | 26 | 27 |
28 |
29 |
30 | 31 | 34 | 35 |
36 |
37 | 38 |
39 |
40 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | # #!/usr/bin/env python3.6 2 | import datetime 3 | import logging 4 | import os 5 | from logging.handlers import TimedRotatingFileHandler 6 | from pathlib import Path 7 | 8 | import tornado.web 9 | from dotenv import load_dotenv 10 | env_path = Path('.') / '.env' 11 | load_dotenv(dotenv_path=env_path) 12 | 13 | from abuse_checker import AbuseChecker 14 | from cds import CdsRequest 15 | from data_processors import WebDataProcessor 16 | from data_providers import get_data_provider 17 | from data_types import AbuseRule 18 | from tgbot import BusBot 19 | from tracking import EventTracker, WebEvent 20 | from website import BusSite 21 | 22 | if not Path('logs').is_dir(): 23 | Path('logs').mkdir() 24 | 25 | 26 | 27 | # Enable logging 28 | file_handler = TimedRotatingFileHandler("logs/vrnbus.log", 'midnight', 1) 29 | file_handler.suffix = "%Y-%m-%d" 30 | logging.basicConfig(format='%(asctime)s.%(msecs)03d - %(levelname)s [%(filename)s:%(lineno)s %(funcName)10s] %(message)s', 31 | datefmt="%H:%M:%S", 32 | level=logging.INFO, 33 | handlers=[logging.StreamHandler(), file_handler]) 34 | 35 | logger = logging.getLogger("vrnbus") 36 | 37 | logger.info([{k: os.environ[k]} for (k) in os.environ if 'PATH' not in k]) 38 | 39 | user_settings = {} 40 | 41 | if __name__ == "__main__": 42 | log_ignore_events = [ 43 | WebEvent.ABUSE, 44 | # WebEvent.FRAUD, 45 | # WebEvent.FULLINFO, 46 | WebEvent.IPCHANGE, 47 | WebEvent.BUSMAP, 48 | # WebEvent.ARRIVAL, 49 | WebEvent.ANDROID, 50 | WebEvent.IOS 51 | ] 52 | 53 | tracker = EventTracker(logger, log_ignore_events) 54 | 55 | abuse_rules = [ 56 | AbuseRule(WebEvent.BUSMAP, 100, datetime.timedelta(minutes=30)), 57 | AbuseRule(WebEvent.BUSINFO, 100, datetime.timedelta(minutes=30)), 58 | ] 59 | 60 | anti_abuser = AbuseChecker(logger, abuse_rules) 61 | data_provider = get_data_provider(logger) 62 | cds = CdsRequest(logger, data_provider) 63 | data_processor = WebDataProcessor(cds, logger, tracker) 64 | bot = BusBot(cds, user_settings, logger, tracker) 65 | cds.wd_call_back = bot.broadcast_message 66 | application = BusSite(data_processor, logger, tracker, anti_abuser) 67 | application.listen(os.environ.get('PORT', 8088)) 68 | tornado.ioloop.IOLoop.current().start() 69 | -------------------------------------------------------------------------------- /fe/feedback.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | vrnbus (beta) | О проекте 9 | 10 | 11 | 17 |
18 | Прогноз прибытия автобусов в Воронеже
19 | Сайт: vrnbus.herokuapp.com
20 | Telegram-бот: @vrnbusbot
21 | Приложения: Android, 22 | iOS
23 |
24 | Город и транспорт 25 |
26 | Telegram: канал @vrntrans, чат @vrntranschat
27 | ВКонтакте: @vrntrans
28 | Facebook: @vrntrans
29 |
30 | Техническое
31 | GitHub проекта: vrntrans/vrnbus
32 | Front-end: чистый JavaScript и поддержка fetch/promises для старых браузеров
33 | Back-end: Python 3.6 (cachetools, fdb, python-telegram-bot, pytz, tornado)
34 | Доклад о проекте: https://www.youtube.com/watch?v=1OtHwGqSL04
35 |
36 | Предложения и пожелания
37 | Пожаловаться через GitHub
38 | Пожаловаться через почту
39 |
40 | Дополнительные возможности (бета беты)
41 | Статистика: /stats.html
42 | Все остановки Воронежа: /busstops.html
43 | Все маршруты Воронежа: /busroutes.html
44 |
45 | Благодарности
46 | Сделано совместно с МБУ ЦОДД
47 |
48 | 49 | -------------------------------------------------------------------------------- /fe/map.css: -------------------------------------------------------------------------------- 1 | .bus-icon { 2 | position: absolute; 3 | width: 32px; 4 | height: 32px; 5 | margin-left: -5px; 6 | margin-top: 0; 7 | } 8 | 9 | .bus-title { 10 | display: block; 11 | background-color: white; 12 | position: absolute; 13 | margin-left: 25px; 14 | margin-top: 25px; 15 | padding: 2px; 16 | color: black; 17 | font-weight: bold; 18 | } 19 | 20 | .bus-title-top { 21 | margin-left: -10px; 22 | margin-top: -10px; 23 | } 24 | 25 | .bus-title-bottom { 26 | margin-left: 25px; 27 | margin-top: 25px; 28 | } 29 | 30 | .hide_info { 31 | display: none; 32 | } 33 | 34 | .spinner { 35 | display: inline-grid; 36 | height: 10px; 37 | width: 10px; 38 | margin: 0 auto; 39 | -webkit-animation: rotation .6s infinite linear; 40 | -moz-animation: rotation .6s infinite linear; 41 | -o-animation: rotation .6s infinite linear; 42 | animation: rotation .6s infinite linear; 43 | border: 2px solid rgba(0, 174, 239, .15); 44 | border-top-color: rgba(0, 174, 239, .8); 45 | border-radius: 100%; 46 | } 47 | 48 | @-webkit-keyframes rotation { 49 | from { 50 | -webkit-transform: rotate(0deg); 51 | } 52 | to { 53 | -webkit-transform: rotate(359deg); 54 | } 55 | } 56 | 57 | @-moz-keyframes rotation { 58 | from { 59 | -moz-transform: rotate(0deg); 60 | } 61 | to { 62 | -moz-transform: rotate(359deg); 63 | } 64 | } 65 | 66 | @-o-keyframes rotation { 67 | from { 68 | -o-transform: rotate(0deg); 69 | } 70 | to { 71 | -o-transform: rotate(359deg); 72 | } 73 | } 74 | 75 | @keyframes rotation { 76 | from { 77 | transform: rotate(0deg); 78 | } 79 | to { 80 | transform: rotate(359deg); 81 | } 82 | } 83 | 84 | div.input-row { 85 | width: 100%; 86 | overflow: hidden; 87 | max-width: 500px; 88 | padding: 3px; 89 | } 90 | 91 | input.input-row { 92 | width: 100%; 93 | } 94 | 95 | span.input-row { 96 | display: block; 97 | overflow: hidden; 98 | padding-right: 10px; 99 | } 100 | 101 | select.input-row { 102 | display: block; 103 | overflow: hidden; 104 | margin-right: 5px; 105 | } 106 | 107 | .input-row-right { 108 | float: right; 109 | } 110 | 111 | .input-row-left { 112 | float: left; 113 | } 114 | 115 | div.menu { 116 | padding-bottom: 10px; 117 | } 118 | 119 | div.menu > div { 120 | margin-right: 2%; 121 | display: inline; 122 | } 123 | 124 | div.menu > div:last-child { 125 | margin-right: 0; 126 | } 127 | 128 | div.map { 129 | width: 100%; 130 | height: 80vh; 131 | } -------------------------------------------------------------------------------- /fe/promises.min.js: -------------------------------------------------------------------------------- 1 | !function(e,n){"object"==typeof exports&&"undefined"!=typeof module?n():"function"==typeof define&&define.amd?define(n):n()}(0,function(){"use strict";function e(){}function n(e){if(!(this instanceof n))throw new TypeError("Promises must be constructed via new");if("function"!=typeof e)throw new TypeError("not a function");this._state=0,this._handled=!1,this._value=undefined,this._deferreds=[],f(e,this)}function t(e,t){for(;3===e._state;)e=e._value;0!==e._state?(e._handled=!0,n._immediateFn(function(){var n=1===e._state?t.onFulfilled:t.onRejected;if(null!==n){var i;try{i=n(e._value)}catch(f){return void r(t.promise,f)}o(t.promise,i)}else(1===e._state?o:r)(t.promise,e._value)})):e._deferreds.push(t)}function o(e,t){try{if(t===e)throw new TypeError("A promise cannot be resolved with itself.");if(t&&("object"==typeof t||"function"==typeof t)){var o=t.then;if(t instanceof n)return e._state=3,e._value=t,void i(e);if("function"==typeof o)return void f(function(e,n){return function(){e.apply(n,arguments)}}(o,t),e)}e._state=1,e._value=t,i(e)}catch(u){r(e,u)}}function r(e,n){e._state=2,e._value=n,i(e)}function i(e){2===e._state&&0===e._deferreds.length&&n._immediateFn(function(){e._handled||n._unhandledRejectionFn(e._value)});for(var o=0,r=e._deferreds.length;r>o;o++)t(e,e._deferreds[o]);e._deferreds=null}function f(e,n){var t=!1;try{e(function(e){t||(t=!0,o(n,e))},function(e){t||(t=!0,r(n,e))})}catch(i){if(t)return;t=!0,r(n,i)}}var u=setTimeout;n.prototype["catch"]=function(e){return this.then(null,e)},n.prototype.then=function(n,o){var r=new this.constructor(e);return t(this,new function(e,n,t){this.onFulfilled="function"==typeof e?e:null,this.onRejected="function"==typeof n?n:null,this.promise=t}(n,o,r)),r},n.prototype["finally"]=function(e){var n=this.constructor;return this.then(function(t){return n.resolve(e()).then(function(){return t})},function(t){return n.resolve(e()).then(function(){return n.reject(t)})})},n.all=function(e){return new n(function(n,t){function o(e,f){try{if(f&&("object"==typeof f||"function"==typeof f)){var u=f.then;if("function"==typeof u)return void u.call(f,function(n){o(e,n)},t)}r[e]=f,0==--i&&n(r)}catch(c){t(c)}}if(!e||"undefined"==typeof e.length)throw new TypeError("Promise.all accepts an array");var r=Array.prototype.slice.call(e);if(0===r.length)return n([]);for(var i=r.length,f=0;r.length>f;f++)o(f,r[f])})},n.resolve=function(e){return e&&"object"==typeof e&&e.constructor===n?e:new n(function(n){n(e)})},n.reject=function(e){return new n(function(n,t){t(e)})},n.race=function(e){return new n(function(n,t){for(var o=0,r=e.length;r>o;o++)e[o].then(n,t)})},n._immediateFn="function"==typeof setImmediate&&function(e){setImmediate(e)}||function(e){u(e,0)},n._unhandledRejectionFn=function(e){void 0!==console&&console&&console.warn("Possible Unhandled Promise Rejection:",e)};var c=function(){if("undefined"!=typeof self)return self;if("undefined"!=typeof window)return window;if(void 0!==c)return c;throw Error("unable to locate global object")}();c.Promise||(c.Promise=n)}); 2 | -------------------------------------------------------------------------------- /fe/busstops.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | 14 | vrnbus (beta) | Карта с остановками 15 | 16 | 17 | 23 |
24 | 25 | 26 |
(Нужно разрешить сайту запрашивать геолокацию)
27 |
28 |
29 |
30 | 32 | 33 |
34 |
35 |
36 | 37 |
38 |
39 | 40 |
41 |
42 | 43 |
44 |
45 | 46 |
47 |
48 |
49 |
50 |
51 | 52 | 56 | 57 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /fe/busroutes.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | 13 | 15 | vrnbus (beta) | Карта с маршрутами 16 | 17 | 18 | 24 |
25 | 26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | 36 |
37 |
38 | 39 | 40 |
41 |
42 |
43 |
44 | 45 | 49 | 50 | 51 | 52 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /test_tracking.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import unittest 4 | 5 | from freezegun import freeze_time 6 | 7 | from abuse_checker import AbuseChecker 8 | from data_types import AbuseRule 9 | from tracking import EventTracker, TgEvent, WebEvent, get_event_by_name 10 | 11 | logging.basicConfig(format='%(asctime)s - %(levelname)s [%(filename)s:%(lineno)s %(funcName)20s] %(message)s', 12 | level=logging.INFO, 13 | handlers=[logging.StreamHandler()]) 14 | 15 | logger = logging.getLogger("vrnbus") 16 | 17 | 18 | class FakeUser(): 19 | def __init__(self, id=None): 20 | self.id = id if id else 42 21 | 22 | 23 | class TrackingEventTest(unittest.TestCase): 24 | def test_event_parsing(self): 25 | f = get_event_by_name 26 | cases = [ 27 | (None, None), 28 | (123, None), 29 | ([], None), 30 | ([1, 2, 3], None), 31 | ('', None), 32 | ('Unknown_event', None), 33 | ('ARRIVAL', WebEvent.ARRIVAL), 34 | ('arrival', WebEvent.ARRIVAL), 35 | ('LaSt', TgEvent.LAST), 36 | ('Tg.last', TgEvent.LAST), 37 | ('Tg.abuse', TgEvent.ABUSE), 38 | ('web.abuse', WebEvent.ABUSE), 39 | ] 40 | 41 | for (event_name, result) in cases: 42 | with self.subTest(f'{event_name}, {result}'): 43 | self.assertEqual(f(event_name), result) 44 | 45 | 46 | class TrackingTest(unittest.TestCase): 47 | def test_something(self): 48 | tracker = EventTracker(logger) 49 | user = FakeUser() 50 | 51 | tracker.tg(TgEvent.START, user) 52 | tracker.web(WebEvent.ARRIVAL, '127.0.0.1') 53 | stats = tracker.stats() 54 | detailed_stats = tracker.stats(True) 55 | logger.info(stats) 56 | logger.info(detailed_stats) 57 | 58 | self.assertEqual(tracker.events[TgEvent.START], 1) 59 | self.assertEqual(tracker.events[WebEvent.ARRIVAL], 1) 60 | self.assertEqual(len(tracker.web_users), 1) 61 | self.assertEqual(len(tracker.tg_users), 1) 62 | 63 | def test_detailed_stats(self): 64 | logger.setLevel(logging.WARNING) 65 | tracker = EventTracker(logger) 66 | user = FakeUser() 67 | 68 | tracker.tg(TgEvent.START, user) 69 | for i in range(500): 70 | tracker.tg(TgEvent(i % 8 + 1), FakeUser(100500 + i % 7)) 71 | tracker.web(WebEvent(i % 3 + 1), f'127.0.0.{i%3}') 72 | stats = tracker.stats() 73 | detailed_stats = tracker.stats(True) 74 | 75 | logger.setLevel(logging.INFO) 76 | logger.info(detailed_stats) 77 | self.assertNotEqual(stats, detailed_stats) 78 | 79 | 80 | class AbuseCheckerTest(unittest.TestCase): 81 | def test_wo_rules(self): 82 | checker = AbuseChecker(logger, []) 83 | checker.add_user_event(WebEvent.BUSMAP, '127.0.0.1') 84 | 85 | @freeze_time("12:00", tick=True) 86 | def test_with_rules_day(self): 87 | self.run_rules_check(False) 88 | 89 | @freeze_time("20:00", tick=True) 90 | def test_with_rules_evening(self): 91 | self.run_rules_check(True) 92 | 93 | @freeze_time("7:59", tick=True) 94 | def test_with_rules_morning(self): 95 | self.run_rules_check(True) 96 | 97 | def run_rules_check(self, check_result): 98 | abuse_rules = [ 99 | AbuseRule(WebEvent.BUSINFO, 10, datetime.timedelta(minutes=60)), 100 | AbuseRule(WebEvent.BUSMAP, 10, datetime.timedelta(minutes=90)), 101 | ] 102 | checker = AbuseChecker(logger, abuse_rules) 103 | self.assertTrue(len(checker.rules) == 2) 104 | user_id = '127.0.0.1' 105 | for _ in range(50): 106 | checker.add_user_event(WebEvent.BUSMAP, user_id) 107 | checker.add_user_event(WebEvent.BUSINFO, user_id) 108 | 109 | self.assertEqual(checker.check_user(WebEvent.BUSMAP, user_id), check_result) 110 | self.assertEqual(checker.check_user(WebEvent.BUSINFO, user_id), check_result) 111 | -------------------------------------------------------------------------------- /tracking.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from collections import defaultdict 3 | from enum import Enum, auto 4 | from typing import List 5 | 6 | 7 | class TgEvent(Enum): 8 | ABUSE = auto() 9 | START = auto() 10 | HELP = auto() 11 | LAST = auto() 12 | NEXT = auto() 13 | STATS = auto() 14 | USER_STATS = auto() 15 | LOCATION = auto() 16 | USER_INPUT = auto() 17 | CUSTOM_CMD = auto() 18 | WRONG_CMD = auto() 19 | 20 | @staticmethod 21 | def from_str(label): 22 | return TgEvent.__dict__.get(label) 23 | 24 | 25 | class WebEvent(Enum): 26 | ABUSE = auto() 27 | FRAUD = auto() 28 | ARRIVAL = auto() 29 | BUSINFO = auto() 30 | BUSMAP = auto() 31 | BUSSTOP = auto() 32 | FOTOBUS = auto() 33 | FULLINFO = auto() 34 | IPCHANGE = auto() 35 | IOS = auto() 36 | ANDROID = auto() 37 | WEB_SITE = auto() 38 | USER_STATS = auto() 39 | COMPLAIN = auto() 40 | 41 | @staticmethod 42 | def from_str(label): 43 | return WebEvent.__dict__.get(label) 44 | 45 | 46 | def get_event_by_name(name: str): 47 | if not name or not isinstance(name, str): 48 | return 49 | if '.' in name: 50 | parts = name.split('.') 51 | if len(parts) > 2: 52 | return 53 | (event_type, event_name) = (i.upper() for i in parts) 54 | if event_type == 'TG': 55 | return TgEvent.from_str(event_name) 56 | if event_type == 'WEB': 57 | return WebEvent.from_str(event_name) 58 | 59 | event_name = name.upper() 60 | web_event = WebEvent.from_str(event_name) 61 | if web_event: 62 | return web_event 63 | tg_event = TgEvent.from_str(event_name) 64 | if tg_event: 65 | return tg_event 66 | 67 | 68 | def get_events_by_names(str_events: List[str]): 69 | result = [] 70 | for item in str_events: 71 | event_name = item.upper() 72 | web_event = WebEvent.get(event_name) 73 | if web_event: 74 | result.append(web_event) 75 | tg_event = TgEvent.get(event_name) 76 | if tg_event: 77 | result.append(tg_event) 78 | 79 | 80 | class EventTracker: 81 | def __init__(self, logger, log_ignore_events: List[Enum] = None): 82 | self.logger = logger 83 | self.log_ignore_events = log_ignore_events or [] 84 | self.events = defaultdict(int) 85 | self.detailed_events = defaultdict(lambda: defaultdict(int)) 86 | self.start = datetime.datetime.now() 87 | self.tg_users = set() 88 | self.web_users = set() 89 | 90 | def add_event(self, event, uid): 91 | self.events[event] += 1 92 | self.detailed_events[event][uid] += 1 93 | 94 | def reset(self): 95 | self.events = defaultdict(int) 96 | self.start = datetime.datetime.now() 97 | self.tg_users = set() 98 | self.web_users = set() 99 | 100 | def stats(self, detailed=False, details_treshold=50, user_filter='', event_filter=None): 101 | def replace_event_name(event): 102 | return str(event).replace("Event.", ".") 103 | 104 | events = [f'{replace_event_name(event_name):13} {sum(event_dict.values())} / {len(event_dict.keys())}' 105 | for event_name, event_dict in self.detailed_events.items()] 106 | events.sort() 107 | user_stats = "\n".join(events) 108 | tg_count = len(self.tg_users) 109 | web_count = len(self.web_users) 110 | user_types = f"Tg users: {tg_count}\nWeb users: {web_count}" 111 | full_info = '' 112 | if detailed: 113 | info_list = [f"{replace_event_name(event_name)}: {k} {v}" 114 | for event_name, event_dict in self.detailed_events.items() 115 | for k, v in event_dict.items() 116 | if v >= details_treshold 117 | or (user_filter and user_filter in k) 118 | or (event_filter and event_name in event_filter) 119 | ] 120 | full_info = "\nDetails\n" + "\n".join(sorted(info_list)) 121 | 122 | return f'{self.start:%Y.%m.%d %H:%M}\n{user_stats}\n{user_types} {full_info}' 123 | 124 | def tg(self, event: TgEvent, user, *params): 125 | user_info = f"user:{user.id}" 126 | self.add_event(event, str(user.id)) 127 | self.tg_users.add(user.id) 128 | if event not in self.log_ignore_events: 129 | self.logger.info(f"TRACK: {event} {user_info} {params if params else ''}") 130 | 131 | def web(self, event: WebEvent, ip, *params): 132 | self.add_event(event, ip) 133 | self.web_users.add(ip) 134 | if event not in self.log_ignore_events: 135 | self.logger.info(f"TRACK: {event} ip:{ip} {params if params else ''}") 136 | -------------------------------------------------------------------------------- /fe/auto-complete.min.js: -------------------------------------------------------------------------------- 1 | // JavaScript autoComplete v1.0.4 2 | // https://github.com/Pixabay/JavaScript-autoComplete 3 | var autoComplete=function(){function e(e){function t(e,t){return e.classList?e.classList.contains(t):new RegExp("\\b"+t+"\\b").test(e.className)}function o(e,t,o){e.attachEvent?e.attachEvent("on"+t,o):e.addEventListener(t,o)}function s(e,t,o){e.detachEvent?e.detachEvent("on"+t,o):e.removeEventListener(t,o)}function n(e,s,n,l){o(l||document,s,function(o){for(var s,l=o.target||o.srcElement;l&&!(s=t(l,e));)l=l.parentElement;s&&n.call(l,o)})}if(document.querySelector){var l={selector:0,source:0,minChars:3,delay:150,offsetLeft:0,offsetTop:1,cache:1,menuClass:"",renderItem:function(e,t){t=t.replace(/[-\/\\^$*+?.()|[\]{}]/g,"\\$&");var o=new RegExp("("+t.split(" ").join("|")+")","gi");return'
'+e.replace(o,"$1")+"
"},onSelect:function(){}};for(var c in e)e.hasOwnProperty(c)&&(l[c]=e[c]);for(var a="object"==typeof l.selector?[l.selector]:document.querySelectorAll(l.selector),u=0;u0?i.sc.scrollTop=n+i.sc.suggestionHeight+s-i.sc.maxHeight:0>n&&(i.sc.scrollTop=n+s)}else i.sc.scrollTop=0},o(window,"resize",i.updateSC),document.body.appendChild(i.sc),n("autocomplete-suggestion","mouseleave",function(){var e=i.sc.querySelector(".autocomplete-suggestion.selected");e&&setTimeout(function(){e.className=e.className.replace("selected","")},20)},i.sc),n("autocomplete-suggestion","mouseover",function(){var e=i.sc.querySelector(".autocomplete-suggestion.selected");e&&(e.className=e.className.replace("selected","")),this.className+=" selected"},i.sc),n("autocomplete-suggestion","mousedown",function(e){if(t(this,"autocomplete-suggestion")){var o=this.getAttribute("data-val");i.value=o,l.onSelect(e,o,this),i.sc.style.display="none"}},i.sc),i.blurHandler=function(){try{var e=document.querySelector(".autocomplete-suggestions:hover")}catch(t){var e=0}e?i!==document.activeElement&&setTimeout(function(){i.focus()},20):(i.last_val=i.value,i.sc.style.display="none",setTimeout(function(){i.sc.style.display="none"},350))},o(i,"blur",i.blurHandler);var r=function(e){var t=i.value;if(i.cache[t]=e,e.length&&t.length>=l.minChars){for(var o="",s=0;st||t>40)&&13!=t&&27!=t){var o=i.value;if(o.length>=l.minChars){if(o!=i.last_val){if(i.last_val=o,clearTimeout(i.timer),l.cache){if(o in i.cache)return void r(i.cache[o]);for(var s=1;s-1?e:t}function l(t,e){e=e||{};var r=e.body;if(t instanceof l){if(t.bodyUsed)throw new TypeError("Already read");this.url=t.url,this.credentials=t.credentials,e.headers||(this.headers=new n(t.headers)),this.method=t.method,this.mode=t.mode,r||null==t._bodyInit||(r=t._bodyInit,t.bodyUsed=!0)}else this.url=String(t);if(this.credentials=e.credentials||this.credentials||"omit",!e.headers&&this.headers||(this.headers=new n(e.headers)),this.method=y(e.method||this.method||"GET"),this.mode=e.mode||this.mode||null,this.referrer=null,("GET"===this.method||"HEAD"===this.method)&&r)throw new TypeError("Body not allowed for GET or HEAD requests");this._initBody(r)}function p(t){var e=new FormData;return t.trim().split("&").forEach(function(t){if(t){var r=t.split("="),o=r.shift().replace(/\+/g," "),n=r.join("=").replace(/\+/g," ");e.append(decodeURIComponent(o),decodeURIComponent(n))}}),e}function c(t){var e=new n;return t.split(/\r?\n/).forEach(function(t){var r=t.split(":"),o=r.shift().trim();if(o){var n=r.join(":").trim();e.append(o,n)}}),e}function b(t,e){e||(e={}),this.type="default",this.status="status"in e?e.status:200,this.ok=this.status>=200&&this.status<300,this.statusText="statusText"in e?e.statusText:"OK",this.headers=new n(e.headers),this.url=e.url||"",this._initBody(t)}if(!t.fetch){var m={searchParams:"URLSearchParams"in t,iterable:"Symbol"in t&&"iterator"in Symbol,blob:"FileReader"in t&&"Blob"in t&&function(){try{return new Blob,!0}catch(t){return!1}}(),formData:"FormData"in t,arrayBuffer:"ArrayBuffer"in t};if(m.arrayBuffer)var w=["[object Int8Array]","[object Uint8Array]","[object Uint8ClampedArray]","[object Int16Array]","[object Uint16Array]","[object Int32Array]","[object Uint32Array]","[object Float32Array]","[object Float64Array]"],v=function(t){return t&&DataView.prototype.isPrototypeOf(t)},B=ArrayBuffer.isView||function(t){return t&&w.indexOf(Object.prototype.toString.call(t))>-1};n.prototype.append=function(t,o){t=e(t),o=r(o);var n=this.map[t];this.map[t]=n?n+","+o:o},n.prototype.delete=function(t){delete this.map[e(t)]},n.prototype.get=function(t){return t=e(t),this.has(t)?this.map[t]:null},n.prototype.has=function(t){return this.map.hasOwnProperty(e(t))},n.prototype.set=function(t,o){this.map[e(t)]=r(o)},n.prototype.forEach=function(t,e){for(var r in this.map)this.map.hasOwnProperty(r)&&t.call(e,this.map[r],r,this)},n.prototype.keys=function(){var t=[];return this.forEach(function(e,r){t.push(r)}),o(t)},n.prototype.values=function(){var t=[];return this.forEach(function(e){t.push(e)}),o(t)},n.prototype.entries=function(){var t=[];return this.forEach(function(e,r){t.push([r,e])}),o(t)},m.iterable&&(n.prototype[Symbol.iterator]=n.prototype.entries);var _=["DELETE","GET","HEAD","OPTIONS","POST","PUT"];l.prototype.clone=function(){return new l(this,{body:this._bodyInit})},d.call(l.prototype),d.call(b.prototype),b.prototype.clone=function(){return new b(this._bodyInit,{status:this.status,statusText:this.statusText,headers:new n(this.headers),url:this.url})},b.error=function(){var t=new b(null,{status:0,statusText:""});return t.type="error",t};var A=[301,302,303,307,308];b.redirect=function(t,e){if(A.indexOf(e)===-1)throw new RangeError("Invalid status code");return new b(null,{status:e,headers:{location:t}})},t.Headers=n,t.Request=l,t.Response=b,t.fetch=function(t,e){return new Promise(function(r,o){var n=new l(t,e),i=new XMLHttpRequest;i.onload=function(){var t={status:i.status,statusText:i.statusText,headers:c(i.getAllResponseHeaders()||"")};t.url="responseURL"in i?i.responseURL:t.headers.get("X-Request-URL");var e="response"in i?i.response:i.responseText;r(new b(e,t))},i.onerror=function(){o(new TypeError("Network request failed"))},i.ontimeout=function(){o(new TypeError("Network request failed"))},i.open(n.method,n.url,!0),"include"===n.credentials&&(i.withCredentials=!0),"responseType"in i&&m.blob&&(i.responseType="blob"),n.headers.forEach(function(t,e){i.setRequestHeader(e,t)}),i.send("undefined"==typeof n._bodyInit?null:n._bodyInit)})},t.fetch.polyfill=!0}}("undefined"!=typeof self?self:this); 2 | //# sourceMappingURL=fetch.min.js.map -------------------------------------------------------------------------------- /data_types.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from enum import Enum 3 | from typing import NamedTuple, List, Dict, Union 4 | 5 | from helpers import distance_km, distance, get_iso_time, QUICK_FIX_DIST 6 | 7 | 8 | class UserLoc(NamedTuple): 9 | lat: float 10 | lon: float 11 | 12 | 13 | class BusStop(NamedTuple): 14 | NAME_: str 15 | LAT_: float 16 | LON_: float 17 | ID: int 18 | AZMTH: int = 0 19 | 20 | def __str__(self): 21 | return f'(BusStop: {self.NAME_} {self.LAT_} {self.LON_} )' 22 | 23 | def distance_km(self, bus_stop): 24 | return distance_km(self.LAT_, self.LON_, bus_stop.LAT_, bus_stop.LON_) 25 | 26 | 27 | class LongBusRouteStop(NamedTuple): 28 | NUMBER_: int 29 | NAME_: str 30 | LAT_: float 31 | LON_: float 32 | ROUT_: int 33 | CONTROL_: int = 0 34 | ID: int = 0 35 | 36 | def distance_km(self, bus_stop): 37 | return distance_km(self.LAT_, self.LON_, bus_stop.LAT_, bus_stop.LON_) 38 | 39 | 40 | class ShortBusRoute(NamedTuple): 41 | NUMBER_: int 42 | ROUT_: int 43 | CONTROL_: int 44 | STOPID: int 45 | 46 | 47 | class CoddNextBus(NamedTuple): 48 | rname_: str 49 | time_: int 50 | 51 | 52 | class CoddBus(NamedTuple): 53 | NAME_: str 54 | ID_: int 55 | ROUTE_ACTIVE_: bool = True 56 | 57 | 58 | class CdsBus(NamedTuple): 59 | obj_id_: int 60 | proj_id_: int 61 | last_speed_: int 62 | last_lon_: float 63 | last_lat_: float 64 | name_: str 65 | last_time_: str 66 | route_name_: str 67 | type_proj: int 68 | phone_: str 69 | 70 | 71 | class CoddRouteBus(NamedTuple): 72 | obj_id_: int 73 | proj_id_: int 74 | last_speed_: int 75 | last_lon_: float 76 | last_lat_: float 77 | lon2: int 78 | lat2: int 79 | azimuth: int 80 | last_time_: str 81 | route_name_: str 82 | type_proj: int 83 | lowfloor: int 84 | dist: int = 0 85 | 86 | 87 | class CdsBusPosition(NamedTuple): 88 | lat: float 89 | lon: float 90 | last_time: datetime.datetime 91 | 92 | def distance(self, bus_stop: BusStop = None, user_loc: UserLoc = None): 93 | if not bus_stop and not user_loc: 94 | return QUICK_FIX_DIST 95 | (lat, lon) = (bus_stop.LAT_, bus_stop.LON_) if bus_stop else (user_loc.lat, user_loc.lon) 96 | if lat is None or lon is None: 97 | return QUICK_FIX_DIST 98 | return distance(lat, lon, self.lat, self.lon) 99 | 100 | def distance_km(self, bus_stop: BusStop = None, position: Union[UserLoc, NamedTuple] = None): 101 | (lat, lon) = (bus_stop.LAT_, bus_stop.LON_) if bus_stop else (position.lat, position.lon) 102 | if lat is None or lon is None: 103 | return QUICK_FIX_DIST 104 | return distance_km(lat, lon, self.lat, self.lon) 105 | 106 | def is_valid_coords(self): 107 | return self.lat != 0.0 and self.lon != 0.0 108 | 109 | 110 | class CdsRouteBus(NamedTuple): 111 | last_lat_: float 112 | last_lon_: float 113 | last_speed_: float 114 | last_time_: datetime.datetime 115 | name_: str 116 | obj_id_: int 117 | proj_id_: int 118 | route_name_: str 119 | type_proj: int = 0 120 | last_station_time_: datetime.datetime = None 121 | bus_station_: str = "" 122 | low_floor: bool = False 123 | bus_type: int = 0 124 | obj_output: int = 0 125 | avg_speed: float = 0 126 | avg_last_speed: float = 0 127 | azimuth: int = 0 128 | bort_name: str = '' 129 | 130 | @staticmethod 131 | def make(last_lat_, last_lon_, last_speed_, last_time_, name_, obj_id_, proj_id_, route_name_, 132 | type_proj, last_station_time_, bus_station_, low_floor=False, bus_type=0, avg_speed=18, azimuth=0, bort_name=""): 133 | try: 134 | last_time_ = get_iso_time(last_time_) 135 | last_station_time_ = get_iso_time(last_station_time_) if last_station_time_ else None 136 | except Exception as e: 137 | print(e) 138 | return CdsRouteBus(last_lat_, last_lon_, last_speed_, last_time_, name_, obj_id_, proj_id_, 139 | route_name_, type_proj, last_station_time_, bus_station_, low_floor, bus_type, avg_speed, azimuth, bort_name) 140 | 141 | def get_bus_position(self) -> CdsBusPosition: 142 | return CdsBusPosition(self.last_lat_, self.last_lon_, self.last_time_) 143 | 144 | def filter_by_name(self, filter_query: str) -> bool: 145 | bus_filter = filter_query.lower().split(' ') 146 | name = self.name_.lower() 147 | bort_name = self.bort_name.lower() 148 | return not filter_query or any((q in name for q in bus_filter if q)) or any((q in bort_name for q in bus_filter if q)) 149 | 150 | def short(self): 151 | return f'{self.bus_station_}; {self.last_lat_} {self.last_lon_} ' 152 | 153 | def distance(self, bus_stop: BusStop = None, user_loc: UserLoc = None): 154 | if not bus_stop and not user_loc: 155 | return QUICK_FIX_DIST 156 | (lat, lon) = (bus_stop.LAT_, bus_stop.LON_) if bus_stop else (user_loc.lat, user_loc.lon) 157 | return distance(lat, lon, self.last_lat_, self.last_lon_) 158 | 159 | def distance_km(self, bus_stop: BusStop = None, user_loc: UserLoc = None): 160 | if not bus_stop and not user_loc: 161 | return QUICK_FIX_DIST 162 | (lat, lon) = (bus_stop.LAT_, bus_stop.LON_) if bus_stop else (user_loc.lat, user_loc.lon) 163 | return distance_km(lat, lon, self.last_lat_, self.last_lon_) 164 | 165 | def is_valid_coords(self): 166 | return self.last_lat_ > 0.0 and self.last_lon_ > 0.0 167 | 168 | 169 | class ArrivalBusStopInfo(NamedTuple): 170 | bus_info: CdsRouteBus 171 | distance: float 172 | time_left: float 173 | 174 | 175 | class ArrivalBusStopInfoFull(NamedTuple): 176 | bus_stop_id: int 177 | bus_stop_name: str 178 | lat: float 179 | lon: float 180 | azmth: int 181 | text: str 182 | bus_routes: List[str] = [] 183 | arrival_buses: List[ArrivalBusStopInfo] = [] 184 | 185 | 186 | class ArrivalInfo(NamedTuple): 187 | text: str 188 | header: str = '' 189 | arrival_details: List[ArrivalBusStopInfoFull] = [] 190 | bus_stops: List[BusStop] = [] 191 | found: bool = False 192 | 193 | 194 | class CdsBaseDataProvider: 195 | CACHE_TIMEOUT = 0 196 | 197 | def now(self) -> datetime.datetime: 198 | pass 199 | 200 | def load_all_cds_buses(self) -> List[CdsRouteBus]: 201 | pass 202 | 203 | def load_codd_route_names(self) -> Dict: 204 | pass 205 | 206 | def load_bus_stations_routes(self) -> Dict: 207 | pass 208 | 209 | def load_bus_stops(self) -> List[BusStop]: 210 | pass 211 | 212 | def load_new_codd_route_names(self): 213 | pass 214 | 215 | def load_new_bus_stations_routes(self): 216 | pass 217 | 218 | 219 | class AbuseRule(NamedTuple): 220 | event: Enum 221 | count: int 222 | delta: datetime.timedelta 223 | 224 | 225 | class StatsData(NamedTuple): 226 | min1: int 227 | min10: int 228 | min30: int 229 | min60: int 230 | total: int 231 | text: str 232 | -------------------------------------------------------------------------------- /helpers.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import logging 4 | import re 5 | import time 6 | from functools import wraps 7 | from itertools import zip_longest, filterfalse 8 | from typing import NamedTuple 9 | 10 | import math 11 | import pytz 12 | 13 | QUICK_FIX_DIST = 10000 14 | 15 | class CustomJsonEncoder(json.JSONEncoder): 16 | def default(self, o): 17 | if isinstance(o, datetime.datetime): 18 | return o.isoformat() 19 | if isinstance(o, set): 20 | return list(o) 21 | return json.JSONEncoder.default(self, o) 22 | 23 | 24 | class SearchResult(NamedTuple): 25 | full_info: bool = False 26 | bus_routes: tuple = tuple() 27 | bus_filter: str = '' 28 | all_buses: bool = False 29 | 30 | 31 | tz = pytz.timezone('Europe/Moscow') 32 | logger = logging.getLogger("vrnbus") 33 | 34 | 35 | def natural_sort_key(s, _nsre=re.compile('([0-9]+)')): 36 | return [int(text) if text.isdigit() else text.lower() 37 | for text in re.split(_nsre, s)] 38 | 39 | def sort_routes(source): 40 | routes = list(source) 41 | is_trolley = lambda x: x.startswith("Т") 42 | trolleys = list(filter(is_trolley, routes)) 43 | trolleys.sort(key=natural_sort_key) 44 | rest = list(filterfalse(is_trolley, routes)) 45 | rest.sort(key=natural_sort_key) 46 | return trolleys + rest 47 | 48 | 49 | def parse_routes(text) -> SearchResult: 50 | if not text: 51 | return SearchResult()._replace(all_buses=True) 52 | if isinstance(text, (list, tuple,)): 53 | text = ' '.join(text) 54 | args = re.split("[ ,;]+", text) 55 | if not args: 56 | return SearchResult() 57 | route_filter = [] 58 | bus_filter_start = False 59 | bus_filter = '' 60 | for i in args: 61 | if i in '\/|': 62 | bus_filter_start = True 63 | continue 64 | if bus_filter_start: 65 | bus_filter += i + ' ' 66 | continue 67 | route_filter.append(i) 68 | if not route_filter: 69 | return SearchResult(bus_filter=bus_filter, all_buses=True) 70 | route_filter = [x.replace('_', ' ') for x in route_filter] 71 | full_info = route_filter[0].upper() in ['PRO', 'ПРО'] 72 | if full_info: 73 | route_filter = route_filter[1:] 74 | 75 | return SearchResult(full_info, tuple(route_filter), bus_filter, not route_filter) 76 | 77 | 78 | def distance(lat1, lon1, lat2, lon2): 79 | if not all((lat1, lon1, lat2, lon2)): 80 | return QUICK_FIX_DIST 81 | return ((lat1 - lat2) ** 2 + (lon1 - lon2) ** 2) ** 0.5 82 | 83 | 84 | def azimuth(glon1, glat1, glon2, glat2): 85 | if not all((glon1, glat1, glon2, glat2)): 86 | return QUICK_FIX_DIST 87 | lat1 = glat1 * math.pi / 180 88 | lat2 = glat2 * math.pi / 180 89 | long1 = glon1 * math.pi / 180 90 | long2 = glon2 * math.pi / 180 91 | cosl1 = math.cos(lat1) 92 | cosl2 = math.cos(lat2) 93 | sinl1 = math.sin(lat1) 94 | sinl2 = math.sin(lat2) 95 | delta = long2 - long1 96 | cdelta = math.cos(delta) 97 | sdelta = math.sin(delta) 98 | x = (cosl1 * sinl2) - (sinl1 * cosl2 * cdelta) 99 | y = sdelta * cosl2 100 | z = (math.atan(-y / x)) / 0.017453293 101 | if (x < 0): 102 | z = z + 180 103 | z2 = (z + 180) % 360 + 180 104 | z2 = -(z2 * 0.017453293) 105 | anglerad2 = z2 - ((2 * math.pi) * math.floor(z2 / (2 * math.pi))) 106 | angledeg = (anglerad2 * 180) / math.pi 107 | azmth = round(angledeg) 108 | 109 | return azmth 110 | 111 | 112 | def parse_int(s, default=0): 113 | try: 114 | return int(s), True 115 | except Exception: 116 | return default, False 117 | 118 | 119 | def distance_km(glat1, glon1, glat2, glon2): 120 | if not all((glat1, glon1, glat2, glon2)): 121 | return QUICK_FIX_DIST 122 | r = 6373.0 123 | 124 | lat1 = math.radians(glat1) 125 | lon1 = math.radians(glon1) 126 | lat2 = math.radians(glat2) 127 | lon2 = math.radians(glon2) 128 | 129 | diff_lon = lon2 - lon1 130 | diff_lat = lat2 - lat1 131 | 132 | a = math.sin(diff_lat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(diff_lon / 2) ** 2 133 | c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) 134 | 135 | result = r * c 136 | return result 137 | 138 | 139 | def get_iso_time(s) -> datetime.datetime: 140 | if isinstance(s, datetime.datetime): 141 | return s 142 | 143 | try: 144 | return datetime.datetime.strptime(s, '%Y-%m-%dT%H:%M:%S.%f') 145 | except ValueError: 146 | return datetime.datetime.strptime(s, '%Y-%m-%dT%H:%M:%S') 147 | 148 | 149 | def get_time(s): 150 | if isinstance(s, datetime.datetime): 151 | return tz.localize(s) 152 | return tz.localize(datetime.datetime.strptime(s, '%b %d, %Y %I:%M:%S %p')) 153 | 154 | 155 | def grouper(n, iterable, fill_value=None): 156 | """grouper(3, 'ABCDEFG', 'x') --> ABC DEF Gxx""" 157 | args = [iter(iterable)] * n 158 | return zip_longest(fillvalue=fill_value, *args) 159 | 160 | 161 | def retry_multi(max_retries=5): 162 | """ Retry a function `max_retries` times. """ 163 | 164 | def retry(func): 165 | @wraps(func) 166 | def wrapper(*args, **kwargs): 167 | num_retries = 0 168 | ret = None 169 | while num_retries <= max_retries: 170 | try: 171 | ret = func(*args, **kwargs) 172 | break 173 | except Exception as e: 174 | logger.exception(e) 175 | if num_retries == max_retries: 176 | raise 177 | num_retries += 1 178 | time.sleep(5) 179 | return ret 180 | 181 | return wrapper 182 | 183 | return retry 184 | 185 | 186 | def fuzzy_search(needle: str, haystack: str) -> bool: 187 | hlen = len(haystack) 188 | nlen = len(needle) 189 | needle = needle.lower() 190 | haystack = haystack.lower() 191 | if nlen > hlen or nlen == 0: 192 | return False 193 | if needle in haystack: 194 | return True 195 | position = 0 196 | for i in range(nlen): 197 | nch = needle[i] 198 | position = haystack.find(nch, position) + 1 199 | if position == 0: 200 | return False 201 | return True 202 | 203 | 204 | def fuzzy_search_advanced(needle: str, haystack: str) -> bool: 205 | hlen = len(haystack) 206 | nlen = len(needle) 207 | needle = needle.lower() 208 | haystack = haystack.lower() 209 | if nlen > hlen or nlen == 0: 210 | return False 211 | 212 | if needle in haystack: 213 | return True 214 | 215 | nch = needle[0] 216 | position = haystack.find(nch) 217 | if position == -1: 218 | return False 219 | 220 | skip_chars = (' ', ',', '(', ')', '.') 221 | if position > 0 and nch not in skip_chars: 222 | while True: 223 | position = haystack.find(nch, position) + 1 224 | if position == 0: 225 | return False 226 | if haystack[position - 2] in skip_chars: 227 | break 228 | 229 | i = 1 230 | while i < nlen: 231 | nch = needle[i] 232 | prev_position = position + 1 233 | position = haystack.find(nch, position) + 1 234 | 235 | if position == 0: 236 | return False 237 | 238 | skip_pos = position - prev_position 239 | 240 | if skip_pos > 0 and haystack[position - 2] not in skip_chars and nch not in skip_chars and i > 1: 241 | i -= 1 242 | continue 243 | 244 | i += 1 245 | 246 | return True 247 | -------------------------------------------------------------------------------- /test_cds.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import time 4 | import unittest 5 | 6 | from cds import CdsRequest 7 | from data_processors import WebDataProcessor 8 | from data_providers import CdsTestDataProvider, CdsDBDataProvider 9 | from data_types import CdsBusPosition, CdsRouteBus, BusStop 10 | from helpers import parse_routes 11 | from tracking import EventTracker 12 | 13 | logging.basicConfig(format='%(asctime)s - %(levelname)s [%(filename)s:%(lineno)s %(funcName)20s] %(message)s', 14 | level=logging.INFO, 15 | handlers=[logging.StreamHandler()]) 16 | 17 | logger = logging.getLogger("vrnbus") 18 | 19 | 20 | class CdsRouteTestCase(unittest.TestCase): 21 | def __init__(self, *args, **kwargs): 22 | super(CdsRouteTestCase, self).__init__(*args, **kwargs) 23 | self.mock_provider = CdsTestDataProvider(logger) 24 | self.cds = CdsRequest(logger, self.mock_provider) 25 | self.date_time = datetime.datetime(2018, 2, 15, 19, 56, 53) 26 | 27 | def test_routes_on_bus_stop(self): 28 | result = self.cds.get_routes_on_bus_stop(57) 29 | self.assertTrue(result) 30 | 31 | def test_bus_stop_distance(self): 32 | route_name = "5А" 33 | stop_1 = "у-м Молодежный (ул. Лизюкова в центр)" 34 | stop_2 = "ул. Лизюкова (ул. Жукова в центр)" 35 | 36 | with self.subTest('Normal bus station order'): 37 | result = self.cds.get_dist(route_name, stop_1, stop_2) 38 | self.assertTrue(result) 39 | 40 | with self.subTest('Reverse bus station order'): 41 | result = self.cds.get_dist(route_name, stop_2, stop_1) 42 | self.assertFalse(result) 43 | 44 | def test_closest_bus_stop_checked(self): 45 | route_name = '5А' 46 | pos_1 = CdsBusPosition(51.705497, 39.149543, self.date_time) # у-м Молодёжный 47 | pos_2 = CdsBusPosition(51.705763, 39.155278, self.date_time) # 60 лет ВЛКСМ 48 | 49 | with self.subTest('From city center '): 50 | result = self.cds.get_closest_bus_stop_checked(route_name, (pos_2, pos_1)) 51 | self.assertTrue(result.NAME_ == 'у-м Молодежный (ул. Лизюкова из центра)') 52 | self.assertEqual(result.NUMBER_, 62) 53 | 54 | with self.subTest('To city center '): 55 | result = self.cds.get_closest_bus_stop_checked(route_name, (pos_1, pos_2)) 56 | self.assertEqual(result.NUMBER_, 5) 57 | 58 | def test_closest_bus_stop_same_stations(self): 59 | positions = [CdsBusPosition(51.667033, 39.193648, self.date_time), 60 | CdsBusPosition(51.672135, 39.187541, self.date_time), 61 | CdsBusPosition(51.675065, 39.185286, self.date_time), 62 | CdsBusPosition(51.677922, 39.184953, self.date_time), 63 | CdsBusPosition(51.677922, 39.184953, self.date_time), 64 | CdsBusPosition(51.680843, 39.184798, self.date_time)] 65 | 66 | result = self.cds.get_closest_bus_stop_checked("90", positions) 67 | 68 | self.assertTrue(result.NUMBER_ == 40) 69 | self.assertTrue(result.NAME_ == 'Проспект Труда (Московский проспект из центра)') 70 | 71 | def test_closest_bus_stop(self): 72 | route_bus = CdsRouteBus.make(*[ 73 | 51.625537, 39.177478, 74 | 16, 75 | "2018-02-15T19:57:47", 76 | "М617АК136", 77 | 834, 78 | 20, 79 | "80", 80 | 0, 81 | "2018-02-15T19:54:56", 82 | "Рабочий проспект (из центра)" 83 | ]) 84 | 85 | station = self.cds.get_closest_bus_stop(route_bus) 86 | logger.info(f"{station}; {route_bus.distance_km(station):.4f} {route_bus.distance(station):.4f}") 87 | 88 | 89 | class CdsDataGatheringTestCase(unittest.TestCase): 90 | def __init__(self, *args, **kwargs): 91 | super(CdsDataGatheringTestCase, self).__init__(*args, **kwargs) 92 | self.mock_provider = CdsTestDataProvider(logger) 93 | 94 | @unittest.skip("testing skipping") 95 | def test_db(self): 96 | self.db_provider = CdsDBDataProvider(logger) 97 | cds = CdsRequest(logger, self.db_provider) 98 | self.call_common_methods(cds) 99 | 100 | def test_mock(self): 101 | cds = CdsRequest(logger, self.mock_provider) 102 | self.call_common_methods(cds) 103 | 104 | def call_common_methods(self, cds): 105 | all_data = cds.load_all_cds_buses_from_db() 106 | cds.calc_avg_speed() 107 | 108 | 109 | class CdsSpeedTestCase(unittest.TestCase): 110 | def __init__(self, *args, **kwargs): 111 | super(CdsSpeedTestCase, self).__init__(*args, **kwargs) 112 | logging.basicConfig(format='%(asctime)s - %(levelname)s [%(filename)s:%(lineno)s %(funcName)20s] %(message)s', 113 | level=logging.INFO, 114 | handlers=[logging.StreamHandler()]) 115 | 116 | logger = logging.getLogger("vrnbus") 117 | self.mock_provider = CdsDBDataProvider(logger) 118 | self.cds = CdsRequest(logger, self.mock_provider) 119 | 120 | def test_avg_speed(self): 121 | avg_speeds = [] 122 | for i in range(50): 123 | time.sleep(0.005) 124 | self.cds.calc_avg_speed() 125 | avg_speeds.append(f"{self.cds.speed_dict['5А']:.2f} {self.cds.speed_dict['125']:.2f}") 126 | logger.info("\n".join(avg_speeds)) 127 | 128 | def test_speed_businfo(self): 129 | query = 'про' 130 | search_request = parse_routes(query) 131 | start = datetime.datetime.now() 132 | result = self.cds.bus_request(search_request, short_format=True) 133 | finish = datetime.datetime.now() 134 | logger.info(f"{finish - start}") 135 | 136 | 137 | class CdsBusStopIndexTestCase(unittest.TestCase): 138 | def __init__(self, *args, **kwargs): 139 | super().__init__(*args, **kwargs) 140 | logging.basicConfig(format='%(asctime)s - %(levelname)s [%(filename)s:%(lineno)s %(funcName)20s] %(message)s', 141 | level=logging.INFO, 142 | handlers=[logging.StreamHandler()]) 143 | 144 | logger = logging.getLogger("vrnbus") 145 | self.mock_provider = CdsTestDataProvider(logger) 146 | self.cds = CdsRequest(logger, self.mock_provider) 147 | 148 | def test_get_index_by_name(self): 149 | result = self.cds.get_bus_stop_id("ул. Кирова (в центр)") 150 | self.assertIsInstance(result, int) 151 | 152 | def test_get_index_for_wrong_name(self): 153 | result = self.cds.get_bus_stop_id("NOT A STATION") 154 | self.assertIsInstance(result, int) 155 | self.assertEqual(result, -1) 156 | 157 | def test_get_bus_stop_for_index(self): 158 | result = self.cds.get_bus_stop_from_id(42) 159 | self.assertIsInstance(result, BusStop) 160 | 161 | def test_get_busstop_for_outrange_indexes(self): 162 | self.assertIsNone(self.cds.get_bus_stop_from_id(-1)) 163 | self.assertIsNone(self.cds.get_bus_stop_from_id(100500)) 164 | 165 | def test_routes_on_near_stations(self): 166 | # TODO: Find the test case for 49A 167 | routes_1 = self.cds.get_routes_on_bus_stop("Политехнический институт (из центра)") 168 | routes_2 = self.cds.get_routes_on_bus_stop("Рабочий проспект (из центра)") 169 | self.assertListEqual(routes_1, routes_2) 170 | 171 | 172 | class CdsBusArrivalTestCases(unittest.TestCase): 173 | def __init__(self, *args, **kwargs): 174 | super().__init__(*args, **kwargs) 175 | logging.basicConfig(format='%(asctime)s - %(levelname)s [%(filename)s:%(lineno)s %(funcName)20s] %(message)s', 176 | level=logging.INFO, 177 | handlers=[logging.StreamHandler()]) 178 | 179 | self.logger = logging.getLogger("vrnbus") 180 | self.tracker = EventTracker(logger, []) 181 | self.mock_provider = CdsTestDataProvider(logger) 182 | self.cds = CdsRequest(logger, self.mock_provider) 183 | self.processor = WebDataProcessor(self.cds, self.logger, self.tracker) 184 | 185 | def test_arrival(self): 186 | result = self.processor.get_arrival("про 49 5а", 51.692727, 39.18297) 187 | stops = result['bus_stops'] 188 | counts = 0 189 | for k, v in stops.items(): 190 | self.logger.info(k) 191 | self.logger.info(v) 192 | counts += len(v.split('\n')) 193 | break 194 | self.logger.info(result) 195 | self.logger.info(counts) 196 | 197 | def test_businfo(self): 198 | result = self.processor.get_bus_info("про 49 5а", 51.692727, 39.18297, True) 199 | self.logger.info(result) 200 | 201 | def test_arrival_distance(self): 202 | src = 'Центральный автовокзал (в центр)' 203 | dst = 'Площадь Застава (в центр)' 204 | wrong_direction = self.cds.get_dist("27", dst, src) 205 | right_direction = self.cds.get_dist("27", src, dst) 206 | self.assertTrue(wrong_direction == 0) 207 | self.assertGreater(right_direction, 0) 208 | 209 | def test_arrival_by_id(self): 210 | for i in range(500): 211 | result = self.processor.get_arrival_by_id("pro", i) 212 | self.assertTrue(result['arrival_info']['found']) 213 | 214 | 215 | if __name__ == '__main__': 216 | unittest.main() 217 | -------------------------------------------------------------------------------- /data_processors.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import os 4 | import random 5 | from logging import Logger 6 | from typing import List 7 | 8 | import cachetools 9 | 10 | from cds import CdsRequest 11 | from data_types import UserLoc, ArrivalInfo, CdsRouteBus 12 | from db import session_scope 13 | from fotobus_scrapper import fb_links 14 | from helpers import parse_routes 15 | from models import RouteEdges 16 | from tracking import EventTracker 17 | 18 | LOAD_TEST_DATA = False 19 | 20 | try: 21 | import settings 22 | 23 | LOAD_TEST_DATA = settings.LOAD_TEST_DATA 24 | except ImportError: 25 | LOAD_TEST_DATA = os.environ.get('LOAD_TEST_DATA', False) 26 | 27 | ttl_sec = 10 if not LOAD_TEST_DATA else 0.0001 28 | 29 | COMPLAINS_EMAIL = os.environ.get('COMPLAINS_EMAIL', 'МБУ ЦОДД ') 30 | 31 | 32 | def isnamedtupleinstance(x): 33 | _type = type(x) 34 | bases = _type.__bases__ 35 | if len(bases) != 1 or bases[0] != tuple: 36 | return False 37 | fields = getattr(_type, '_fields', None) 38 | if not isinstance(fields, tuple): 39 | return False 40 | return all(type(i) == str for i in fields) 41 | 42 | 43 | def unpack_namedtuples(obj): 44 | if isinstance(obj, dict): 45 | return {key: unpack_namedtuples(value) for key, value in obj.items()} 46 | elif isinstance(obj, list): 47 | return [unpack_namedtuples(value) for value in obj] 48 | elif isnamedtupleinstance(obj): 49 | return {key: unpack_namedtuples(value) for key, value in obj._asdict().items()} 50 | elif isinstance(obj, tuple): 51 | return tuple(unpack_namedtuples(value) for value in obj) 52 | else: 53 | return obj 54 | 55 | 56 | def eliminate_numbers(d: dict, full_info, is_fraud) -> dict: 57 | if not full_info: 58 | d['hidden_name'] = d['name_'] 59 | d['name_'] = d['bort_name'] 60 | 61 | if is_fraud: 62 | d['last_lat_'] += random.uniform(-0.05, 0.05) 63 | d['last_lon_'] += random.uniform(-0.05, 0.05) 64 | d['obj_id_'] = random.uniform(0, 2000) 65 | d['proj_id_'] = random.uniform(0, 2000) 66 | raise Exception("Wrong request") 67 | 68 | return d 69 | 70 | 71 | class BaseDataProcessor: 72 | def __init__(self, cds: CdsRequest, logger: Logger, tracker: EventTracker): 73 | self.cds = cds 74 | self.logger = logger 75 | self.tracker = tracker 76 | 77 | 78 | class WebDataProcessor(BaseDataProcessor): 79 | def __init__(self, cds: CdsRequest, logger: Logger, tracker: EventTracker): 80 | super().__init__(cds, logger, tracker) 81 | 82 | @cachetools.func.ttl_cache(ttl=ttl_sec, maxsize=4096) 83 | def get_bus_info(self, query, lat, lon, full_info, hide_text=True): 84 | user_loc = None 85 | if lat and lon: 86 | user_loc = UserLoc(float(lat), float(lon)) 87 | routes_info = parse_routes(query) 88 | is_fraud = not full_info and len(routes_info.bus_routes) > 25 89 | 90 | result = self.cds.bus_request(routes_info, user_loc=user_loc, short_format=True) 91 | return {'q': query, 92 | 'server_time': datetime.datetime.now(), 93 | 'text': '' if hide_text else result[0], 94 | 'buses': [(eliminate_numbers(x[0]._asdict(), full_info, is_fraud), 95 | x[1]._asdict() if x[1] and not is_fraud else {}) for x 96 | in result[1]]} 97 | 98 | @cachetools.func.ttl_cache(ttl=ttl_sec, maxsize=4096) 99 | def get_email_complain(self, query): 100 | routes_info = parse_routes(" pro \ " + query, ) 101 | result = self.cds.bus_request(routes_info) 102 | buses = result[1] 103 | self.logger.info(f"{query} {result=}") 104 | if not buses: 105 | return None 106 | 107 | buses_with_bort_name = [x[0] for x in buses if x[0].bort_name == query] 108 | if not buses_with_bort_name: 109 | return None 110 | 111 | bus:CdsRouteBus = buses_with_bort_name[0] 112 | self.logger.info(f"{bus=}") 113 | 114 | subject = f'Обращение с сайта vrnbus:{datetime.date.today()}, маршрут {bus.route_name_}, номер {bus.bort_name}' 115 | 116 | br = '%0D%0A' 117 | body = f"""Обращение с сайта vrnbus: {datetime.date.today()}, маршрут {bus.route_name_}, номер {bus.bort_name} 118 | Дата и время обращения: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')} 119 | Примерное время и место (уточните, если значительно отличается): {bus.last_station_time_:%Y-%m-%d %H:%M}, {bus.bus_station_} 120 | Обращение: опишите свою жалобу/пожелание/благодарность, при необходимости прикрепите фото/видео, ссылки и т.д. 121 | """.replace("\n", br) 122 | 123 | email_complains = f'mailto:{COMPLAINS_EMAIL}?subject={subject}&body={body}' 124 | 125 | return email_complains 126 | 127 | def get_arrival(self, query, lat, lon): 128 | matches = self.cds.matches_bus_stops(lat, lon) 129 | self.logger.info(f'{lat};{lon} {";".join([str(i) for i in matches])}') 130 | result_tuple = self.cds.next_bus_for_matches(tuple(matches), parse_routes(query)) 131 | response = {'lat': lat, 'lon': lon, 132 | 'text': result_tuple[0], 'header': result_tuple[1], 133 | 'server_time': datetime.datetime.now(), 134 | 'bus_stops': {v.bus_stop_name: v.text for v in result_tuple.arrival_details}} 135 | return response 136 | 137 | @cachetools.func.ttl_cache(ttl=ttl_sec) 138 | def get_arrival_by_name(self, query, station_query): 139 | result_tuple = self.cds.next_bus(station_query, parse_routes(query)) 140 | if result_tuple.found: 141 | response = {'text': result_tuple[0], 'header': result_tuple[1], 142 | 'server_time': datetime.datetime.now(), 143 | 'bus_stops': {v.bus_stop_name: v.text for v in 144 | result_tuple.arrival_details}} 145 | else: 146 | response = {'text': result_tuple[0], 'header': result_tuple[1], 147 | 'server_time': datetime.datetime.now(), 148 | 'bus_stops': {k: '' for k in 149 | result_tuple.bus_stops}} 150 | return response 151 | 152 | def get_text_from_arrival_info(self, arrival_info: ArrivalInfo): 153 | def text_for_bus_stop(value): 154 | return f"({value.bus_stop_id}) {value.bus_stop_name}\n{value.text}" 155 | 156 | next_bus_text = '\n'.join([text_for_bus_stop(v) for v in arrival_info.arrival_details]) 157 | return f'{arrival_info.header}\n{next_bus_text}' 158 | 159 | def get_arrival_by_id(self, query, busstop_id): 160 | bus_stop = self.cds.get_bus_stop_from_id(busstop_id) 161 | if bus_stop: 162 | search_params = parse_routes(query) 163 | arrival_info = self.cds.next_bus_for_matches((bus_stop,), search_params) 164 | result_text = self.get_text_from_arrival_info(arrival_info) 165 | response = {'result': result_text, 'server_time': datetime.datetime.now(), 166 | 'arrival_info': unpack_namedtuples(arrival_info)} 167 | return response 168 | 169 | def get_bus_list(self): 170 | response = {'result': self.cds.codd_buses} 171 | return response 172 | 173 | @cachetools.func.ttl_cache(ttl=36000) 174 | def get_bus_stops(self): 175 | response = {'result': [x._asdict() for x in self.cds.all_bus_stops]} 176 | return response 177 | 178 | @cachetools.func.ttl_cache(ttl=36000) 179 | def get_fotobus_url(self, name): 180 | links = fb_links(name) 181 | return links 182 | 183 | @cachetools.func.ttl_cache(ttl=15) 184 | def get_route_edges(self): 185 | with session_scope(f'Return all RouteEdges') as session: 186 | edges: List[RouteEdges] = session.query(RouteEdges).all() 187 | return [{"edge_key": json.loads(x.edge_key), 188 | "points": json.loads(x.points)} for x in edges] 189 | 190 | def add_route_edges(self, edge_key, points): 191 | with session_scope(f'RouteEdges id {edge_key}') as session: 192 | edge = session.query(RouteEdges).filter_by(edge_key=edge_key).first() 193 | if not edge: 194 | edge = RouteEdges(edge_key=edge_key) 195 | session.add(edge) 196 | edge.points = points 197 | session.commit() 198 | 199 | @cachetools.func.ttl_cache(ttl=36000) 200 | def get_bus_stops_for_routes(self): 201 | response = {route_name: [x._asdict() for x in bus_stops] for (route_name, bus_stops) in 202 | self.cds.bus_routes.items()} 203 | return response 204 | 205 | @cachetools.func.ttl_cache(ttl=36000) 206 | def get_bus_stops_for_routes_for_apps(self): 207 | response = self.cds.bus_routes 208 | return response 209 | 210 | @cachetools.func.ttl_cache(ttl=15) 211 | def get_stats(self): 212 | user_stats = self.tracker.stats() 213 | bus_stats = self.cds.get_bus_statistics() 214 | return str(datetime.datetime.now()) + '\n\n' + user_stats + '\n\n' + bus_stats.text 215 | 216 | @cachetools.func.ttl_cache(ttl=36000) 217 | def get_new_routes(self): 218 | response = {'result': self.cds.codd_new_buses} 219 | return response 220 | 221 | @cachetools.func.ttl_cache(ttl=60) 222 | def get_bus_stops_for_new_routes(self): 223 | response = {route_name: [x._asdict() for x in bus_stops] for (route_name, bus_stops) in 224 | self.cds.get_new_bus_routes().items()} 225 | return response 226 | 227 | 228 | class TelegramDataProcessor(BaseDataProcessor): 229 | def __init__(self, cds: CdsRequest, logger: Logger): 230 | super().__init__(cds, logger) 231 | -------------------------------------------------------------------------------- /website.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from concurrent.futures import ThreadPoolExecutor 4 | from pathlib import Path 5 | 6 | import tornado.web 7 | 8 | import helpers 9 | from abuse_checker import AbuseChecker 10 | from data_processors import WebDataProcessor 11 | from tracking import WebEvent, EventTracker 12 | 13 | if 'DYNO' in os.environ: 14 | debug = False 15 | else: 16 | debug = True 17 | 18 | FULL_ACCESS_KEY = os.environ.get('FULL_ACCESS_KEY', '') 19 | 20 | try: 21 | import settings 22 | 23 | FULL_ACCESS_KEY = settings.FULL_ACCESS_KEY 24 | except ImportError: 25 | FULL_ACCESS_KEY = os.environ.get('FULL_ACCESS_KEY', '') 26 | 27 | 28 | # noinspection PyAbstractClass 29 | class NoCacheStaticFileHandler(tornado.web.StaticFileHandler): 30 | def set_extra_headers(self, path): 31 | self.set_header("Cache-control", "no-cache") 32 | 33 | 34 | class BaseHandler(tornado.web.RequestHandler): 35 | executor = ThreadPoolExecutor() 36 | 37 | def prepare(self): 38 | if not self.get_cookie("user_ip"): 39 | self.set_cookie("user_ip", self.remote_ip, expires_days=30) 40 | 41 | def track(self, event: WebEvent, *params): 42 | if 'CFNetwork' in self.user_agent: 43 | self.tracker.web(WebEvent.IOS, self.user_ip, *params, self.user_agent) 44 | elif 'Dalvik' in self.user_agent or 'Android' in self.user_agent: 45 | self.tracker.web(WebEvent.ANDROID, self.user_ip, *params, self.user_agent) 46 | else: 47 | self.tracker.web(WebEvent.WEB_SITE, self.user_ip, *params, self.user_agent) 48 | 49 | self.tracker.web(event, self.user_ip, *params, self.user_agent) 50 | 51 | def data_received(self, chunk): 52 | pass 53 | 54 | def set_default_headers(self): 55 | self.set_header("Access-Control-Allow-Origin", "*") 56 | self.set_header("Access-Control-Allow-Headers", "x-requested-with") 57 | self.set_header('Access-Control-Allow-Methods', 'POST, GET, OPTIONS') 58 | 59 | def set_extra_headers(self, _): 60 | self.set_header("Tk", "N") 61 | self.caching() 62 | 63 | def caching(self, max_age=15): 64 | self.set_header("Content-Type", "application/json; charset=utf-8") 65 | self.set_header("Cache-Control", f"max-age={max_age}") 66 | 67 | @property 68 | def user_ip(self): 69 | return self.get_cookie("user_ip", self.remote_ip) 70 | 71 | @property 72 | def remote_ip(self): 73 | return self.request.headers.get('X-Forwarded-For', 74 | self.request.headers.get('X-Real-Ip', self.request.remote_ip)) 75 | 76 | @property 77 | def anti_abuser(self): 78 | return self.application.anti_abuser 79 | 80 | @property 81 | def referer(self): 82 | return self.request.headers.get("Referer", "") 83 | 84 | @property 85 | def full_access(self): 86 | return self.referer and FULL_ACCESS_KEY in self.referer 87 | 88 | @property 89 | def user_agent(self): 90 | return self.request.headers["User-Agent"] 91 | 92 | @property 93 | def processor(self) -> WebDataProcessor: 94 | return self.application.processor 95 | 96 | @property 97 | def logger(self): 98 | return self.application.logger 99 | 100 | @property 101 | def tracker(self) -> EventTracker: 102 | return self.application.tracker 103 | 104 | @property 105 | def is_mobile(self): 106 | return any((x in self.user_agent for x in ['CFNetwork', 'Dalvik', 'Android',])) 107 | 108 | class BusSite(tornado.web.Application): 109 | def __init__(self, processor: WebDataProcessor, logger, tracker: EventTracker, anti_abuser: AbuseChecker): 110 | static_handler = tornado.web.StaticFileHandler if not debug else NoCacheStaticFileHandler 111 | handlers = [ 112 | (r"/arrival", ArrivalHandler), 113 | (r"/arrival_by_id", ArrivalByIdHandler), 114 | (r"/codd_arrival_by_id", ArrivalByIdHandler), 115 | (r"/busmap", BusInfoHandler), 116 | (r"/businfolist", BusInfoHandler), 117 | (r"/buslist", BusListHandler), 118 | (r"/new_routes", NewRoutesHandler), 119 | (r"/bus_stop_search", BusStopSearchHandler), 120 | (r"/bus_stops_routes", BusStopsRoutesHandler), 121 | (r"/bus_stops_new_routes", BusStopsNewRoutesHandler), 122 | (r"/bus_stations.json", BusStopsRoutesForAppsHandler), 123 | (r"/bus_stops", BusStopsHandler), 124 | (r"/fotobus_info", FotoBusHandler), 125 | (r"/complains", EmailFromBusHandler), 126 | (r"/bus_route_edges", BusRouteEdgesHandler), 127 | (r"/ping", PingHandler), 128 | (r"/(.*.json)", static_handler, {"path": Path("./")}), 129 | (r"/stats.html", StatsHandler), 130 | (r"/(.*)", static_handler, {"path": Path("./fe"), "default_filename": "index.html"}), 131 | ] 132 | tornado.web.Application.__init__(self, handlers, compress_response=True) 133 | self.logger = logger 134 | self.processor = processor 135 | self.tracker = tracker 136 | self.anti_abuser = anti_abuser 137 | 138 | 139 | class PingHandler(BaseHandler): 140 | def get(self): 141 | self.logger.info('PING') 142 | self.write("PONG") 143 | self.caching(max_age=600) 144 | 145 | 146 | class BusInfoHandler(BaseHandler): 147 | def bus_info_response(self, src, query, lat, lon, parent_url, hide_text): 148 | is_map = src == 'map' 149 | if self.referer and 'vrnbus.herokuapp.com' not in self.referer: 150 | self.track(WebEvent.FRAUD, self.referer, query, lat, lon) 151 | if parent_url: 152 | self.track(WebEvent.FRAUD, parent_url, query, lat, lon) 153 | event = WebEvent.BUSMAP if is_map else WebEvent.BUSINFO 154 | if self.user_ip != self.remote_ip: 155 | self.track(WebEvent.IPCHANGE, f'{self.user_ip} != {self.remote_ip}') 156 | if self.full_access: 157 | self.track(WebEvent.FULLINFO, self.referer, query, lat, lon) 158 | if not self.anti_abuser.add_user_event(event, self.user_ip) and not self.full_access: 159 | self.track(WebEvent.ABUSE, query, lat, lon) 160 | return self.send_error(500) 161 | self.track(event, src, query, lat, lon) 162 | response = self.processor.get_bus_info(query, lat, lon, self.full_access, hide_text) 163 | self.write(json.dumps(response, cls=helpers.CustomJsonEncoder)) 164 | self.caching() 165 | 166 | def get(self): 167 | q = self.get_argument('q') 168 | src = self.get_argument('src', None) 169 | lat = self.get_argument('lat', None) 170 | lon = self.get_argument('lon', None) 171 | parent_url = self.get_argument('parentUrl', None) 172 | hide_text = self.get_argument('hide_text', None) is not None 173 | 174 | self.bus_info_response(src, q, lat, lon, parent_url, hide_text) 175 | 176 | 177 | class ArrivalHandler(BaseHandler): 178 | def arrival_response(self): 179 | (lat, lon) = (float(self.get_argument(x)) for x in ('lat', 'lon')) 180 | query = self.get_argument('q') 181 | self.track(WebEvent.ARRIVAL, query, lat, lon) 182 | response = self.processor.get_arrival(query, lat, lon) 183 | self.write(json.dumps(response, cls=helpers.CustomJsonEncoder)) 184 | self.caching() 185 | 186 | def get(self): 187 | self.arrival_response() 188 | 189 | 190 | class ArrivalByIdHandler(BaseHandler): 191 | def arrival_response(self): 192 | busstop_id = int(self.get_argument('id')) 193 | query = self.get_argument('q', "") 194 | self.track(WebEvent.ARRIVAL, query, busstop_id) 195 | response = self.processor.get_arrival_by_id(query, busstop_id) 196 | self.write(json.dumps(response, cls=helpers.CustomJsonEncoder)) 197 | self.caching() 198 | 199 | def get(self): 200 | self.arrival_response() 201 | 202 | 203 | class BusListHandler(BaseHandler): 204 | def _response(self): 205 | response = self.processor.get_bus_list() 206 | self.write(json.dumps(response)) 207 | if response: 208 | self.caching(max_age=24 * 60 * 60) 209 | 210 | def get(self): 211 | self._response() 212 | 213 | 214 | class NewRoutesHandler(BaseHandler): 215 | def _response(self): 216 | response = self.processor.get_new_routes() 217 | self.write(json.dumps(response)) 218 | if response: 219 | self.caching(max_age=24 * 60 * 60) 220 | 221 | def get(self): 222 | self._response() 223 | 224 | 225 | class BusStopsHandler(BaseHandler): 226 | def _response(self): 227 | response = self.processor.get_bus_stops() 228 | self.write(json.dumps(response, ensure_ascii=False)) 229 | if response: 230 | self.caching(max_age=24 * 60 * 60) 231 | 232 | def get(self): 233 | self._response() 234 | 235 | class BusStopsRoutesForAppsHandler(BaseHandler): 236 | def _response(self): 237 | response = self.processor.get_bus_stops_for_routes_for_apps() 238 | self.write(json.dumps(response, ensure_ascii=False, indent=1, cls=helpers.CustomJsonEncoder)) 239 | if response: 240 | self.caching(max_age=24 * 60 * 60) 241 | 242 | def get(self): 243 | self._response() 244 | 245 | class BusStopsRoutesHandler(BaseHandler): 246 | def _response(self): 247 | response = self.processor.get_bus_stops_for_routes() 248 | self.write(json.dumps(response, ensure_ascii=False)) 249 | if response: 250 | self.caching(max_age=24 * 60 * 60) 251 | 252 | def get(self): 253 | self._response() 254 | 255 | 256 | class BusStopsNewRoutesHandler(BaseHandler): 257 | def _response(self): 258 | response = self.processor.get_bus_stops_for_new_routes() 259 | self.write(json.dumps(response, ensure_ascii=False)) 260 | if response: 261 | self.caching(max_age=24 * 60 * 60) 262 | 263 | def get(self): 264 | self._response() 265 | 266 | 267 | class BusStopSearchHandler(BaseHandler): 268 | def _response(self): 269 | query = self.get_argument('q') 270 | station_query = self.get_argument('station') 271 | self.track(WebEvent.BUSSTOP, query, station_query) 272 | response = self.processor.get_arrival_by_name(query, station_query) 273 | self.logger.info(response) 274 | self.write(json.dumps(response, cls=helpers.CustomJsonEncoder)) 275 | self.caching() 276 | 277 | def get(self): 278 | self._response() 279 | 280 | 281 | class FotoBusHandler(BaseHandler): 282 | def _response(self): 283 | name = self.get_argument('name') 284 | self.track(WebEvent.FOTOBUS, name) 285 | links = self.processor.get_fotobus_url(name) 286 | if links: 287 | self.redirect(links[0]) 288 | else: 289 | self.send_error(404) 290 | 291 | def get(self): 292 | self._response() 293 | 294 | 295 | class StatsHandler(BaseHandler): 296 | def arrival_response(self): 297 | self.track(WebEvent.USER_STATS) 298 | response = self.processor.get_stats() 299 | self.write(response) 300 | self.caching() 301 | 302 | def get(self): 303 | self.arrival_response() 304 | 305 | 306 | class BusRouteEdgesHandler(BaseHandler): 307 | def arrival_response(self): 308 | data = tornado.escape.json_decode(self.request.body) 309 | self.write(data) 310 | self.caching() 311 | 312 | def post(self): 313 | if not self.full_access: 314 | self.send_error(401, reason="Wrong the page URL") 315 | return 316 | data = tornado.escape.json_decode(self.request.body) 317 | edge_key = json.dumps(data.get("edge_key")) 318 | points = json.dumps(data.get("points")) 319 | self.processor.add_route_edges(edge_key, points) 320 | 321 | def get(self): 322 | result = self.processor.get_route_edges() 323 | self.write(json.dumps(result)) 324 | self.caching() 325 | 326 | 327 | class EmailFromBusHandler(BaseHandler): 328 | def _response(self): 329 | bort_number = self.get_argument('bort_number') 330 | test = self.get_argument('test', None) 331 | self.track(WebEvent.COMPLAIN, bort_number) 332 | complain = self.processor.get_email_complain(bort_number) 333 | if not complain: 334 | self.write(f'Автобус с бортовым номером {bort_number} на линии не найден') 335 | return 336 | 337 | if test: 338 | txt_complain = complain.replace('%0D%0A', '
\n').replace('&', '&
\n') 339 | self.write(f'{txt_complain}') 340 | else: 341 | self.redirect(complain) 342 | 343 | def get(self): 344 | self._response() -------------------------------------------------------------------------------- /fe/busroutes.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | var map; 4 | var marker_group; 5 | var my_renderer; 6 | var coords = {latitude: 51.6754966, longitude: 39.2088823} 7 | 8 | var timer_id = 0 9 | var timer_stop_id = 0 10 | 11 | var bus_route_stops = [] 12 | var edited_edges = {} 13 | var bus_stop_auto_complete 14 | var drawn_items 15 | 16 | var route_run = document.getElementById('route_run') 17 | var current_route = [] 18 | var icon_urls = [] 19 | var current_bus_stop_id = 0 20 | var show_old_routes = false 21 | 22 | 23 | document.getElementById('route_stop').onclick = function(){ 24 | clearTimeout(timer_id) 25 | timer_id = 0 26 | timer_stop_id = 0 27 | } 28 | 29 | document.getElementById('show_old_routes').onclick = function(cb){ 30 | show_old_routes = cb.currentTarget.checked 31 | update_route_info(show_old_routes) 32 | } 33 | 34 | function run_timer(func) { 35 | if (!timer_id) { 36 | timer_id = setTimeout(function tick() { 37 | func().then(function () { 38 | timer_id = setTimeout(tick, 1 * 500) 39 | }).catch(function(){ 40 | clearTimeout(timer_id) 41 | timer_id = 0 42 | timer_stop_id = 0 43 | }) 44 | }, 1 * 100) 45 | if (!timer_stop_id) 46 | timer_stop_id = setTimeout(function () { 47 | clearTimeout(timer_id) 48 | timer_id = 0 49 | timer_stop_id = 0 50 | 51 | }, 2* 60 * 1000) 52 | } 53 | } 54 | 55 | route_run.onclick = function () { 56 | run_timer(function(){ 57 | if (!current_route || !current_route.length){ 58 | return Promise.resolve() 59 | } 60 | marker_group.clearLayers() 61 | 62 | var busnumber = document.getElementById('busnumber').value; 63 | var busspeed = document.getElementById('busspeed').value; 64 | var wait_time = document.getElementById('wait_time').value; 65 | 66 | var bus_stop_interval = Math.floor(current_route.length / busnumber) 67 | 68 | if (current_bus_stop_id >= current_route.length){ 69 | current_bus_stop_id = 0; 70 | } 71 | current_bus_stop_id++ 72 | 73 | for(var i=0; i < busnumber; i++){ 74 | var curr_id = (current_bus_stop_id + i * bus_stop_interval) % current_route.length 75 | if (curr_id >= current_route.length){ 76 | curr_id = 0; 77 | } 78 | 79 | var bus_stop = current_route[curr_id] 80 | console.log('bus_stop ', bus_stop) 81 | add_png_marker(bus_stop, i) 82 | .addTo(marker_group) 83 | .bindTooltip(i + ' ' + bus_stop.NAME_, {permanent: true}) 84 | } 85 | return Promise.resolve() 86 | }) 87 | } 88 | 89 | function add_png_marker(item, index) { 90 | var shadowUrl = 'https://unpkg.com/leaflet@1.3.3/dist/images/marker-shadow.png' 91 | 92 | var icon = new L.Icon({ 93 | iconUrl: icon_urls[index % icon_urls.length], 94 | shadowUrl: shadowUrl, 95 | iconSize: [25, 41], 96 | iconAnchor: [12, 41], 97 | popupAnchor: [1, -34], 98 | shadowSize: [41, 41] 99 | }); 100 | 101 | return L.marker([item.LAT_, item.LON_], 102 | { 103 | icon: icon 104 | } 105 | ) 106 | } 107 | 108 | function get_bus_stops_routes(old_routes) { 109 | if (bus_route_stops.length > 0) { 110 | return update_bus_stops_routes(bus_route_stops) 111 | } 112 | 113 | return fetch(old_routes ? '/bus_stops_routes' : '/bus_stops_new_routes', 114 | { 115 | method: 'GET', 116 | headers: { 117 | 'Content-Type': 'application/json' 118 | }, 119 | }) 120 | .then(function (res) { 121 | return res.json() 122 | }) 123 | .then(function (data) { 124 | bus_route_stops = data 125 | // update_bus_stops_routes(data) 126 | }) 127 | .then(function () { 128 | return fetch('/bus_route_edges', { 129 | method: 'GET'}) 130 | }).then(function (res) { 131 | return res.json() 132 | }). 133 | then(function (data) { 134 | data.forEach(function (item) { 135 | edited_edges[item.edge_key] = item.points 136 | }) 137 | }) 138 | } 139 | 140 | 141 | 142 | function get_bus_list(old_routes) { 143 | return fetch(old_routes ? '/buslist' : '/new_routes', 144 | { 145 | method: 'GET', 146 | headers: { 147 | 'Content-Type': 'application/json' 148 | }, 149 | credentials: 'include', 150 | }) 151 | .then(function (res) { 152 | return res.json() 153 | }) 154 | .then(function (data) { 155 | var bus_list = data.result 156 | 157 | var select = document.getElementById('bus_routes') 158 | while (select.options.length > 0) { 159 | select.remove(0); 160 | } 161 | select.appendChild(new Option('Все маршруты', '')) 162 | bus_list.forEach(function (bus_name) { 163 | var opt = new Option(bus_name, bus_name) 164 | select.appendChild(opt) 165 | }) 166 | 167 | select.onchange = function () { 168 | var route_name = select.options[select.selectedIndex].value; 169 | if (route_name || document.getElementById('show_all_routes').checked){ 170 | update_bus_stops_routes(bus_route_stops, route_name) 171 | } 172 | } 173 | 174 | document.getElementById('busnumber').onchange = select.onchange; 175 | document.getElementById('busspeed').onchange = select.onchange; 176 | document.getElementById('show_all_routes').onchange = select.onchange; 177 | document.getElementById('show_old_routes').onchange = select.onchange; 178 | document.getElementById('wait_time').onchange = select.onchange; 179 | }) 180 | } 181 | 182 | 183 | function update_bus_stops_routes(bus_stops_routes, selected_route_name) { 184 | drawn_items.clearLayers() 185 | var my_renderer = L.canvas({padding: 0.5}); 186 | var edges = {} 187 | var bus_stops = {} 188 | var wrong_stops = [] 189 | var route_by_edges = {} 190 | 191 | var natural_collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'}); 192 | 193 | 194 | 195 | for (var route_name in bus_stops_routes) { 196 | if (!route_name) 197 | continue 198 | if (selected_route_name && route_name !== selected_route_name) 199 | continue 200 | var route = bus_stops_routes[route_name] 201 | current_route = bus_stops_routes[route_name] 202 | var curr_point = route[0] 203 | route.forEach(function (item) { 204 | if (curr_point === item) { 205 | return 206 | } 207 | 208 | var edge_key = [curr_point.ID, item.ID] 209 | var edge_info = `( ${edge_key} ) ${curr_point.NAME_} - ${item.NAME_}` 210 | if (!(edge_key in route_by_edges)){ 211 | route_by_edges[edge_key] = [] 212 | } 213 | 214 | route_by_edges[edge_key].push(route_name) 215 | route_by_edges[edge_key].sort(natural_collator.compare) 216 | 217 | var edge = edges[edge_key] 218 | var routes = route_by_edges[edge_key] 219 | var popup_content = `${edge_info}
` + routes.join('
') 220 | 221 | if (edge_key in edges) { 222 | edge.setPopupContent(popup_content) 223 | curr_point = item 224 | return 225 | } 226 | 227 | if (curr_point.NUMBER_ === item.NUMBER_) { 228 | console.log(edge_info) 229 | } 230 | 231 | var pointA = new L.LatLng(curr_point.LAT_, curr_point.LON_); 232 | var pointB = new L.LatLng(item.LAT_, item.LON_); 233 | var pointList = [pointA, pointB]; 234 | if (edited_edges[edge_key]){ 235 | pointList = edited_edges[edge_key]; 236 | } 237 | 238 | var firstpolyline = new L.Polyline(pointList, { 239 | color: 'blue', 240 | weight: 5, 241 | opacity: 0.5, 242 | smoothFactor: 1, 243 | edge_key: edge_key, 244 | edge_info: edge_info 245 | }).bindPopup(popup_content); 246 | // firstpolyline.addTo(map); 247 | drawn_items.addLayer(firstpolyline) 248 | 249 | edges[edge_key] = firstpolyline 250 | 251 | curr_point = item 252 | 253 | }) 254 | } 255 | 256 | if (selected_route_name) { 257 | var length_route = 0; 258 | var previousPoint = null; 259 | Object.values(edges).forEach((polyline) => { 260 | polyline.getLatLngs().forEach(function (latLng) { 261 | if (previousPoint) { 262 | length_route += previousPoint.distanceTo(latLng)/1000 263 | } 264 | previousPoint = latLng; 265 | }); 266 | }) 267 | var routeinfo = document.getElementById('routeinfo'); 268 | var busnumber = document.getElementById('busnumber').value; 269 | var busspeed = document.getElementById('busspeed').value; 270 | var wait_time = document.getElementById('wait_time').value; 271 | var minute_interval = ((length_route + (wait_time/60)*busspeed)/busnumber)*60/busspeed; 272 | routeinfo.innerText = `${selected_route_name} - ${length_route.toFixed(2)} км, ${busnumber}, ${minute_interval.toFixed(2)} минут ` 273 | } 274 | } 275 | 276 | function save_to_ls(key, value) { 277 | if (!ls_test()) { 278 | return 279 | } 280 | localStorage.setItem(key, value) 281 | } 282 | 283 | function load_from_ls(key) { 284 | if (!ls_test()) { 285 | return 286 | } 287 | return localStorage.getItem(key) 288 | } 289 | 290 | function ls_test() { 291 | var test = 'test' 292 | if (!'localStorage' in window) { 293 | return false 294 | } 295 | try { 296 | localStorage.setItem(test, test) 297 | localStorage.removeItem(test) 298 | return true 299 | } catch (e) { 300 | return false 301 | } 302 | } 303 | 304 | function update_route_info(old_routes) { 305 | drawn_items.clearLayers() 306 | get_bus_list(old_routes) 307 | get_bus_stops_routes(old_routes) 308 | } 309 | 310 | function init() { 311 | map = L.map('mapid', { 312 | fullscreenControl: { 313 | pseudoFullscreen: true // if true, fullscreen to page width and height 314 | }, 315 | minZoom: 10, 316 | maxZoom: 22 317 | }).setView([51.6754966, 39.2088823], 11) 318 | 319 | my_renderer = L.canvas({padding: 0.5}); 320 | 321 | L.tileLayer('https://b.tile.openstreetmap.org/{z}/{x}/{y}.png', { 322 | attribution: '© OpenStreetMap contributors', 323 | minZoom: 10, 324 | maxZoom: 22, 325 | maxNativeZoom: 18 326 | }).addTo(map) 327 | 328 | // FeatureGroup is to store editable layers 329 | drawn_items = new L.FeatureGroup(); 330 | map.addLayer(drawn_items); 331 | 332 | map.addControl(new L.Control.Draw({ 333 | edit: { 334 | featureGroup: drawn_items, 335 | poly: { 336 | allowIntersection: true 337 | } 338 | }, 339 | draw: { 340 | polygon: { 341 | allowIntersection: true, 342 | showArea: true 343 | } 344 | } 345 | })); 346 | 347 | 348 | var iconUrls = [ 349 | 'marker-icon-2x-blue.png', 350 | 'marker-icon-2x-green.png', 351 | 'marker-icon-2x-red.png', 352 | ] 353 | 354 | var base_color_marker_url = 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/' 355 | iconUrls.forEach(function (value) { 356 | icon_urls.push(base_color_marker_url + value) 357 | }) 358 | 359 | map.on("draw:created", function (event) { 360 | var layer = event.layer; 361 | }); 362 | 363 | map.on('draw:edited', function (e) { 364 | var layers = e.layers; 365 | layers.eachLayer(function (edited_layer) { 366 | var edge_key = edited_layer.options.edge_key; 367 | var points = edited_layer.editing.latlngs[0]; 368 | edited_edges[edited_layer.options.edge_key] = points; 369 | fetch('/bus_route_edges', { 370 | method: 'POST', // или 'PUT' 371 | body: JSON.stringify({ 372 | 'edge_key': edge_key, 373 | 'points': points 374 | }), // данные могут быть 'строкой' или {объектом}! 375 | headers: {'Content-Type': 'application/json'}, 376 | credentials: 'include', 377 | }) 378 | .then(res => res.ok ? res : Promise.reject(res)) 379 | .catch(error => { 380 | console.error(error) 381 | alert(`Ошибка при редактировании маршрута: ${error.status} ${error.statusText} `) 382 | }) 383 | }); 384 | }); 385 | 386 | marker_group = L.layerGroup().addTo(map); 387 | L.control.ruler().addTo(map); 388 | 389 | update_route_info() 390 | } 391 | 392 | document.addEventListener("DOMContentLoaded", init); 393 | })() -------------------------------------------------------------------------------- /fe/main.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | if (location.protocol !== 'https:' && location.hostname !== 'localhost') { 5 | location.href = 'https:' + window.location.href.substring(window.location.protocol.length); 6 | } 7 | 8 | var coords = {latitude: 51.6754966, longitude: 39.2088823} 9 | 10 | var lastbusquery = document.getElementById('lastbusquery') 11 | var station_query = document.getElementById('station_query') 12 | var station_name = document.getElementById('station_name') 13 | 14 | var timer_id = 0 15 | var timer_stop_id = 0 16 | 17 | var info = document.getElementById('info') 18 | var businfo = document.getElementById('businfo') 19 | var lastbus = document.getElementById('lastbus') 20 | var nextbus_loading = document.getElementById('nextbus_loading') 21 | var lastbus_loading = document.getElementById('lastbus_loading') 22 | var cb_refresh = document.getElementById('cb_refresh') 23 | var cb_show_info = document.getElementById('cb_show_info') 24 | var btn_station_search = document.getElementById('btn_station_search') 25 | 26 | var bus_stop_list = [] 27 | var bus_stop_names = [] 28 | var bus_stop_auto_complete 29 | 30 | if (lastbus) 31 | lastbus.onclick = function () { 32 | get_cds_bus() 33 | } 34 | 35 | if (cb_refresh) 36 | cb_refresh.onclick = function () { 37 | if (!cb_refresh.checked) { 38 | clearTimeout(timer_id) 39 | clearTimeout(timer_stop_id) 40 | timer_id = 0 41 | timer_stop_id = 0 42 | } 43 | } 44 | 45 | if (cb_show_info) { 46 | cb_show_info.onclick = function () { 47 | var show = cb_show_info.checked 48 | businfo.className = show ? "" : "hide_info" 49 | } 50 | } 51 | 52 | if (btn_station_search) { 53 | btn_station_search.onclick = function () { 54 | run_search_by_name() 55 | } 56 | } 57 | if (lastbusquery) { 58 | lastbusquery.onkeyup = function (event) { 59 | event.preventDefault() 60 | if (event.keyCode === 13) { 61 | get_cds_bus() 62 | } 63 | } 64 | } 65 | 66 | if (station_query) { 67 | station_query.onkeyup = function (event) { 68 | event.preventDefault() 69 | if (event.keyCode === 13) { 70 | run_search_by_name() 71 | } 72 | } 73 | } 74 | 75 | if (station_name) { 76 | station_name.onkeyup = function (event) { 77 | event.preventDefault() 78 | if (event.keyCode === 13) { 79 | run_search_by_name() 80 | } 81 | } 82 | } 83 | 84 | function run_timer(func) { 85 | if (cb_refresh.checked && !timer_id) { 86 | timer_id = setTimeout(function tick() { 87 | func().then(function () { 88 | if (cb_refresh.checked) 89 | timer_id = setTimeout(tick, 30 * 1000) 90 | }) 91 | }, 30 * 1000) 92 | if (!timer_stop_id) 93 | timer_stop_id = setTimeout(function () { 94 | cb_refresh.checked = false 95 | clearTimeout(timer_id) 96 | timer_id = 0 97 | timer_stop_id = 0 98 | 99 | }, 10 * 60 * 1000) 100 | } 101 | } 102 | 103 | function setCookie(name, value, options) { 104 | options = options || {}; 105 | 106 | var expires = options.expires; 107 | 108 | if (typeof expires == "number" && expires) { 109 | var d = new Date(); 110 | d.setTime(d.getTime() + expires * 1000); 111 | expires = options.expires = d; 112 | } 113 | if (expires && expires.toUTCString) { 114 | options.expires = expires.toUTCString(); 115 | } 116 | 117 | value = encodeURIComponent(value); 118 | 119 | var updatedCookie = name + "=" + value; 120 | 121 | for (var propName in options) { 122 | updatedCookie += "; " + propName; 123 | var propValue = options[propName]; 124 | if (propValue !== true) { 125 | updatedCookie += "=" + propValue; 126 | } 127 | } 128 | 129 | document.cookie = updatedCookie; 130 | } 131 | 132 | function getCookie(name) { 133 | var matches = document.cookie.match(new RegExp( 134 | "(?:^|; )" + name.replace(/([\.$?*|{}\(\)\[\]\\\/\+^])/g, '\\$1') + "=([^;]*)" 135 | )); 136 | return matches ? decodeURIComponent(matches[1]) : undefined; 137 | } 138 | 139 | function run_search_by_name() { 140 | run_timer(run_search_by_name) 141 | return get_bus_arrival_by_name() 142 | } 143 | 144 | function get_cds_bus() { 145 | run_timer(get_cds_bus) 146 | 147 | var bus_query = lastbusquery.value 148 | save_to_ls('bus_query', bus_query) 149 | return get_bus_positions(bus_query) 150 | } 151 | 152 | function update_user_position() { 153 | if ("geolocation" in navigator) { 154 | navigator.geolocation.getCurrentPosition(function (position) { 155 | coords = position.coords 156 | }) 157 | } 158 | } 159 | 160 | if ("geolocation" in navigator) { 161 | var nextbus = document.getElementById('nextbus') 162 | 163 | if (nextbus) 164 | nextbus.onclick = function (event) { 165 | event.preventDefault() 166 | get_current_pos(get_bus_arrival) 167 | } 168 | } 169 | 170 | function save_station_params() { 171 | var query = station_query.value 172 | var station = station_name.value 173 | 174 | save_to_ls('station_query', query) 175 | save_to_ls('station', station) 176 | } 177 | 178 | function get_current_pos(func) { 179 | save_station_params() 180 | 181 | navigator.geolocation.getCurrentPosition(func) 182 | } 183 | 184 | function format_bus_stops(header, bus_stops) { 185 | var bus_stop_info = header + '\n' 186 | for (var prop in bus_stops) { 187 | bus_stop_info += '' + prop + '' + '\n' + bus_stops[prop] + '\n' 188 | } 189 | 190 | info.innerHTML = bus_stop_info 191 | var elements = document.getElementsByClassName('bus_linked') 192 | for (var i = 0; i < elements.length; i++) { 193 | elements[i].onclick = function (e) { 194 | e.preventDefault() 195 | if (e.srcElement && e.srcElement.text) { 196 | station_name.value = e.srcElement.text 197 | get_bus_arrival_by_name() 198 | } 199 | } 200 | } 201 | } 202 | 203 | function get_bus_arrival_by_name() { 204 | var btn_station_search = document.getElementById('btn_station_search') 205 | waiting(nextbus_loading, btn_station_search, true) 206 | 207 | var bus_query = station_query.value 208 | var station = station_name.value 209 | 210 | save_station_params() 211 | 212 | var params = 'q=' + encodeURIComponent(bus_query) + 213 | '&station=' + encodeURIComponent(station) 214 | 215 | return fetch('/bus_stop_search?' + params, 216 | { 217 | method: 'GET', 218 | headers: { 219 | 'Content-Type': 'application/json' 220 | }, 221 | credentials: 'include', 222 | }) 223 | .then(function (res) { 224 | return res.json() 225 | }) 226 | .then(function (data) { 227 | update_cookies() 228 | waiting(nextbus_loading, btn_station_search, false) 229 | format_bus_stops(data.header, data.bus_stops) 230 | }) 231 | .catch(function (error) { 232 | waiting(nextbus_loading, btn_station_search, false) 233 | info.innerHTML = 'Ошибка: ' + error 234 | }) 235 | } 236 | 237 | function get_bus_arrival(position) { 238 | var nextbus = document.getElementById('nextbus') 239 | waiting(nextbus_loading, nextbus, true) 240 | 241 | coords = position.coords 242 | var bus_query = station_query.value 243 | 244 | var params = 'q=' + encodeURIComponent(bus_query) + 245 | '&lat=' + encodeURIComponent(coords.latitude) + 246 | '&lon=' + encodeURIComponent(coords.longitude) 247 | 248 | return fetch('/arrival?' + params, 249 | { 250 | method: 'GET', 251 | headers: { 252 | 'Content-Type': 'application/json' 253 | }, 254 | credentials: 'include', 255 | }) 256 | .then(function (res) { 257 | return res.json() 258 | }) 259 | .then(function (data) { 260 | update_cookies() 261 | waiting(nextbus_loading, nextbus, false) 262 | format_bus_stops(data.header, data.bus_stops) 263 | }) 264 | .catch(function (error) { 265 | waiting(nextbus_loading, nextbus, false) 266 | info.innerHTML = 'Ошибка: ' + error 267 | }) 268 | } 269 | 270 | 271 | function waiting(element, button, state) { 272 | element.className = state ? 'spinner' : '' 273 | button.disabled = state 274 | } 275 | 276 | function fraud_check() { 277 | if (parent !== window){ 278 | return "&parentUrl=" + encodeURIComponent(document.referrer) 279 | } 280 | return "" 281 | } 282 | 283 | function get_bus_positions(query) { 284 | waiting(lastbus_loading, lastbus, true) 285 | 286 | var params = 'q=' + encodeURIComponent(query) + fraud_check() 287 | if (coords) { 288 | params += '&lat=' + encodeURIComponent(coords.latitude) 289 | params += '&lon=' + encodeURIComponent(coords.longitude) 290 | } 291 | 292 | return fetch('/businfolist?' + params, 293 | { 294 | method: 'GET', 295 | headers: { 296 | 'Content-Type': 'application/json' 297 | }, 298 | credentials: 'include', 299 | }) 300 | .then(function (res) { 301 | return res.json() 302 | }) 303 | .then(function (data) { 304 | update_cookies() 305 | waiting(lastbus_loading, lastbus, false) 306 | var q = data.q 307 | var text = data.text 308 | businfo.innerHTML = 'Маршруты: ' + q + '\nКоличество результатов: ' + data.buses.length + '\n' + text 309 | }).catch(function (error) { 310 | waiting(lastbus_loading, lastbus, false) 311 | 312 | businfo.innerHTML = 'Ошибка: ' + error 313 | }) 314 | } 315 | 316 | function get_bus_stop_list() { 317 | return fetch('/bus_stops', 318 | { 319 | method: 'GET', 320 | headers: { 321 | 'Content-Type': 'application/json' 322 | }, 323 | }) 324 | .then(function (res) { 325 | return res.json() 326 | }) 327 | .then(function (data) { 328 | update_cookies() 329 | bus_stop_list = data.result 330 | bus_stop_names = bus_stop_list.map(function callback(bus_stop) { 331 | return bus_stop.NAME_ 332 | }) 333 | if (station_name) { 334 | bus_stop_auto_complete = new autoComplete({ 335 | selector: station_name, 336 | source: function (term, suggest) { 337 | term = term.toLowerCase(); 338 | var matches = []; 339 | for (var i = 0; i < bus_stop_names.length; i++) 340 | if (~bus_stop_names[i].toLowerCase().indexOf(term)) matches.push(bus_stop_names[i]); 341 | suggest(matches); 342 | } 343 | }) 344 | } 345 | }) 346 | } 347 | 348 | function update_cookies() { 349 | var user_ip = getCookie("user_ip") 350 | if (user_ip) { 351 | save_to_ls("user_ip", user_ip) 352 | } 353 | } 354 | 355 | function get_bus_list() { 356 | return fetch('/buslist', 357 | { 358 | method: 'GET', 359 | headers: { 360 | 'Content-Type': 'application/json' 361 | }, 362 | credentials: 'include', 363 | }) 364 | .then(function (res) { 365 | return res.json() 366 | }) 367 | .then(function (data) { 368 | update_cookies() 369 | var bus_list = data.result 370 | 371 | var select = document.getElementById('buslist') 372 | select.appendChild(new Option('Маршруты', '-')) 373 | bus_list.forEach(function (bus_name) { 374 | var opt = new Option(bus_name, bus_name) 375 | select.appendChild(opt) 376 | }) 377 | 378 | select.onchange = function () { 379 | var text = select.options[select.selectedIndex].value; // Текстовое значение для выбранного option 380 | if (text !== '-') { 381 | if (lastbusquery) 382 | lastbusquery.value += ' ' + text 383 | if (station_query) 384 | station_query.value += ' ' + text 385 | } 386 | } 387 | }) 388 | } 389 | 390 | function save_to_ls(key, value) { 391 | if (!ls_test()) { 392 | return 393 | } 394 | localStorage.setItem(key, value) 395 | } 396 | 397 | function load_from_ls(key) { 398 | if (!ls_test()) { 399 | return 400 | } 401 | return localStorage.getItem(key) 402 | } 403 | 404 | function ls_test() { 405 | var test = 'test' 406 | if (!'localStorage' in window) { 407 | return false 408 | } 409 | try { 410 | localStorage.setItem(test, test) 411 | localStorage.removeItem(test) 412 | return true 413 | } catch (e) { 414 | return false 415 | } 416 | } 417 | 418 | function init() { 419 | var user_ip = getCookie("user_ip") 420 | var ls_user_ip = load_from_ls('user_ip') 421 | if (!user_ip && ls_user_ip) { 422 | setCookie("user_ip", ls_user_ip, {expires: 3600 * 24 * 7}) 423 | } 424 | if (station_name) { 425 | get_bus_stop_list() 426 | } 427 | get_bus_list() 428 | 429 | if (lastbusquery) 430 | lastbusquery.value = load_from_ls('bus_query') || '' 431 | 432 | if (station_query) 433 | station_query.value = load_from_ls('station_query') || '' 434 | 435 | if (station_name) 436 | station_name.value = load_from_ls('station') || '' 437 | } 438 | 439 | document.addEventListener("DOMContentLoaded", init); 440 | })() -------------------------------------------------------------------------------- /fe/busstops.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | if (location.protocol !== 'https:' && location.hostname !== 'localhost') { 5 | location.href = 'https:' + window.location.href.substring(window.location.protocol.length); 6 | } 7 | 8 | var l_map; 9 | var marker_group; 10 | var my_renderer; 11 | var coords = {latitude: 51.6754966, longitude: 39.2088823} 12 | 13 | var lastbusquery = document.getElementById('lastbusquery') 14 | var station_query = document.getElementById('station_query') 15 | var station_name = document.getElementById('station_name') 16 | 17 | var timer_id = 0 18 | var timer_stop_id = 0 19 | 20 | var info = document.getElementById('info') 21 | var businfo = document.getElementById('businfo') 22 | var lastbus = document.getElementById('lastbus') 23 | var nextbus_loading = document.getElementById('nextbus_loading') 24 | var lastbus_loading = document.getElementById('lastbus_loading') 25 | var cb_show_labels = document.getElementById('cb_show_labels') 26 | var cb_show_id_only = document.getElementById('cb_show_id_only') 27 | var cb_show_png_markers = document.getElementById('cb_show_png_markers') 28 | var cb_show_info = document.getElementById('cb_show_info') 29 | var cb_animation = document.getElementById('cb_animation') 30 | var btn_station_search = document.getElementById('btn_station_search') 31 | 32 | var bus_stop_list = [] 33 | var bus_stop_auto_complete 34 | 35 | if (lastbus) 36 | lastbus.onclick = function () { 37 | get_cds_bus() 38 | } 39 | 40 | if (cb_show_labels) { 41 | cb_show_labels.onclick = function () { 42 | get_bus_stop_list() 43 | } 44 | } 45 | 46 | if (cb_show_id_only) { 47 | cb_show_id_only.onclick = function () { 48 | get_bus_stop_list() 49 | } 50 | } 51 | 52 | if (cb_show_png_markers) { 53 | cb_show_png_markers.onclick = function () { 54 | get_bus_stop_list() 55 | } 56 | } 57 | if (cb_show_info) { 58 | cb_show_info.onclick = function () { 59 | var show = cb_show_info.checked 60 | businfo.className = show ? "" : "hide_info" 61 | } 62 | } 63 | 64 | if (btn_station_search) { 65 | btn_station_search.onclick = function () { 66 | run_search_by_name() 67 | } 68 | } 69 | if (lastbusquery) { 70 | lastbusquery.onkeyup = function (event) { 71 | event.preventDefault() 72 | if (event.keyCode === 13) { 73 | get_cds_bus() 74 | } 75 | } 76 | } 77 | 78 | if (station_query) { 79 | station_query.onkeyup = function (event) { 80 | event.preventDefault() 81 | if (event.keyCode === 13) { 82 | run_search_by_name() 83 | } 84 | } 85 | } 86 | 87 | function run_timer(func) { 88 | if (cb_show_labels.checked && !timer_id) { 89 | timer_id = setTimeout(function tick() { 90 | func().then(function () { 91 | if (cb_show_labels.checked) 92 | timer_id = setTimeout(tick, 30 * 1000) 93 | }) 94 | }, 30 * 1000) 95 | if (!timer_stop_id) 96 | timer_stop_id = setTimeout(function () { 97 | cb_show_labels.checked = false 98 | clearTimeout(timer_id) 99 | timer_id = 0 100 | timer_stop_id = 0 101 | 102 | }, 10 * 60 * 1000) 103 | } 104 | } 105 | 106 | function setCookie(name, value, options) { 107 | options = options || {}; 108 | 109 | var expires = options.expires; 110 | 111 | if (typeof expires === "number" && expires) { 112 | var d = new Date(); 113 | d.setTime(d.getTime() + expires * 1000); 114 | expires = options.expires = d; 115 | } 116 | if (expires && expires.toUTCString) { 117 | options.expires = expires.toUTCString(); 118 | } 119 | 120 | value = encodeURIComponent(value); 121 | 122 | var updatedCookie = name + "=" + value; 123 | 124 | for (var propName in options) { 125 | updatedCookie += "; " + propName; 126 | var propValue = options[propName]; 127 | if (propValue !== true) { 128 | updatedCookie += "=" + propValue; 129 | } 130 | } 131 | 132 | document.cookie = updatedCookie; 133 | } 134 | 135 | function getCookie(name) { 136 | var matches = document.cookie.match(new RegExp( 137 | "(?:^|; )" + name.replace(/([\.$?*|{}\(\)\[\]\\\/\+^])/g, '\\$1') + "=([^;]*)" 138 | )); 139 | return matches ? decodeURIComponent(matches[1]) : undefined; 140 | } 141 | 142 | function update_user_position() { 143 | if ("geolocation" in navigator) { 144 | navigator.geolocation.getCurrentPosition(function (position) { 145 | coords = position.coords 146 | }) 147 | } 148 | } 149 | 150 | if ("geolocation" in navigator) { 151 | var nextbus = document.getElementById('nextbus') 152 | 153 | if (nextbus) 154 | nextbus.onclick = function (event) { 155 | event.preventDefault() 156 | get_current_pos(get_bus_arrival) 157 | } 158 | } 159 | 160 | function save_station_params() { 161 | var query = station_query.value 162 | var station = station_name.value 163 | 164 | save_to_ls('station_query', query) 165 | save_to_ls('station', station) 166 | } 167 | 168 | function get_current_pos(func) { 169 | save_station_params() 170 | 171 | navigator.geolocation.getCurrentPosition(func) 172 | } 173 | 174 | function format_bus_stops(header, bus_stops) { 175 | var bus_stop_info = header + '\n' 176 | for (var prop in bus_stops) { 177 | bus_stop_info += '' + prop + '' + '\n' + bus_stops[prop] + '\n' 178 | } 179 | 180 | info.innerHTML = bus_stop_info 181 | var elements = document.getElementsByClassName('bus_linked') 182 | for (var i = 0; i < elements.length; i++) { 183 | elements[i].onclick = function (e) { 184 | e.preventDefault() 185 | if (e.srcElement && e.srcElement.text) { 186 | station_name.value = e.srcElement.text 187 | get_bus_arrival_by_name() 188 | } 189 | } 190 | } 191 | } 192 | 193 | function waiting(element, button, state) { 194 | element.className = state ? 'spinner' : '' 195 | button.disabled = state 196 | } 197 | 198 | 199 | function get_bus_stop_list() { 200 | var show_labels = cb_show_labels.checked; 201 | var show_id_only = cb_show_id_only.checked; 202 | var show_png_markers = cb_show_png_markers.checked; 203 | 204 | if (bus_stop_list.length > 0) { 205 | return update_bus_stops(bus_stop_list, show_labels, show_id_only, show_png_markers) 206 | } 207 | 208 | return fetch('/bus_stops', 209 | { 210 | method: 'GET', 211 | headers: { 212 | 'Content-Type': 'application/json' 213 | }, 214 | }) 215 | .then(function (res) { 216 | return res.json() 217 | }) 218 | .then(function (data) { 219 | update_cookies() 220 | bus_stop_list = data.result 221 | 222 | update_bus_stops_autocomplete(bus_stop_list, show_labels) 223 | update_bus_stops(bus_stop_list) 224 | }) 225 | } 226 | 227 | function update_bus_stops_autocomplete(bus_stop_list) { 228 | update_cookies() 229 | 230 | if (!station_name) { 231 | return; 232 | } 233 | 234 | bus_stop_auto_complete = new autoComplete({ 235 | selector: station_name, 236 | minChars: 1, 237 | source: function (term, suggest) { 238 | term = term.toLowerCase(); 239 | var id = parseInt(term, 10); 240 | var matches = []; 241 | for (var i = 0; i < bus_stop_list.length; i++) { 242 | var bus_stop = bus_stop_list[i] 243 | var name_with_id = bus_stop.ID + " " + bus_stop.NAME_ 244 | if (Number.isInteger(id) && id.toString(10) === term) { 245 | if (id === bus_stop.ID) { 246 | matches.push(bus_stop); 247 | } 248 | } else if (~name_with_id.toLowerCase().indexOf(term)) { 249 | matches.push(bus_stop); 250 | } 251 | } 252 | suggest(matches); 253 | }, 254 | renderItem: function (item, search) { 255 | search = search.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); 256 | var re = new RegExp("(" + search.split(' ').join('|') + ")", "gi"); 257 | var name_with_id = item.ID + " " + item.NAME_ 258 | var data_values = 'data-lat="' + item.LAT_ + '" data-lng="' + item.LON_ + '"' 259 | return '
' 260 | + name_with_id.replace(re, "$1") + '
'; 261 | }, 262 | onSelect: function (e, term, item) { 263 | var lat = item.getAttribute('data-lat') 264 | var lng = item.getAttribute('data-lng') 265 | if (cb_animation.checked) 266 | l_map.flyTo([lat, lng], 17) 267 | else 268 | l_map.setView([lat, lng], 17) 269 | } 270 | }) 271 | } 272 | 273 | function add_circle_marker(item) { 274 | var marker_colors = ["#3388ff", 275 | "#330088", 276 | "#ff662e"] 277 | 278 | return L.circleMarker([item.LAT_, item.LON_], { 279 | renderer: my_renderer, 280 | fill: true, 281 | fillOpacity: 0.9, 282 | color: "#3388ff" 283 | }).on('click', function (e) { 284 | 285 | var color_index = marker_colors.indexOf(e.target.options.color) + 1 286 | if (color_index >= marker_colors.length) { 287 | color_index = 0 288 | } 289 | 290 | e.target.setStyle({ 291 | color: marker_colors[color_index] 292 | }) 293 | }); 294 | } 295 | 296 | var icon_urls = [] 297 | 298 | function add_png_marker(item) { 299 | var shadowUrl = 'https://unpkg.com/leaflet@1.3.3/dist/images/marker-shadow.png' 300 | 301 | var icon = new L.Icon({ 302 | iconUrl: icon_urls[0], 303 | shadowUrl: shadowUrl, 304 | iconSize: [25, 41], 305 | iconAnchor: [12, 41], 306 | popupAnchor: [1, -34], 307 | shadowSize: [41, 41] 308 | }); 309 | 310 | return L.marker([item.LAT_, item.LON_], 311 | { 312 | icon: icon 313 | } 314 | ).on('click', function (e) { 315 | 316 | var icon_index = icon_urls.indexOf(e.target.options.icon.options.iconUrl) + 1 317 | if (icon_index >= icon_urls.length) { 318 | icon_index = 0 319 | } 320 | 321 | var icon = new L.Icon({ 322 | iconUrl: icon_urls[icon_index], 323 | shadowUrl: shadowUrl, 324 | iconSize: [25, 41], 325 | iconAnchor: [12, 41], 326 | popupAnchor: [1, -34], 327 | shadowSize: [41, 41] 328 | }); 329 | 330 | this.setIcon(icon) 331 | }) 332 | } 333 | 334 | 335 | function update_bus_stops(bus_stop_list, show_tooltips_always, show_id_only, show_png_markers) { 336 | marker_group.clearLayers() 337 | var wrong_stops = [] 338 | 339 | bus_stop_list.forEach(function (item) { 340 | if (!item.LAT_ || !item.LON_ || item.LON_ < 30 || item.LAT_ < 30) { 341 | wrong_stops.push(item); 342 | return 343 | } 344 | 345 | var tooltip_text = show_id_only ? "" + item.ID : item.ID + " " + item.NAME_; 346 | var marker = show_png_markers ? add_png_marker(item) : add_circle_marker(item) 347 | 348 | marker 349 | .addTo(marker_group) 350 | .bindTooltip(tooltip_text, {permanent: show_tooltips_always}) 351 | }) 352 | 353 | var wrong_stops_info = "Проверьте координаты остановок:
" 354 | wrong_stops.forEach(function (item) { 355 | wrong_stops_info += item.ID + " " + item.NAME_ + " (" + item.LAT_ + ", " + item.LON_ + ") " + "
" 356 | }) 357 | businfo.innerHTML = wrong_stops_info 358 | } 359 | 360 | function update_cookies() { 361 | var user_ip = getCookie("user_ip") 362 | if (user_ip) { 363 | save_to_ls("user_ip", user_ip) 364 | } 365 | } 366 | 367 | function save_to_ls(key, value) { 368 | if (!ls_test()) { 369 | return 370 | } 371 | localStorage.setItem(key, value) 372 | } 373 | 374 | function load_from_ls(key) { 375 | if (!ls_test()) { 376 | return 377 | } 378 | return localStorage.getItem(key) 379 | } 380 | 381 | function ls_test() { 382 | var test = 'test' 383 | if (!'localStorage' in window) { 384 | return false 385 | } 386 | try { 387 | localStorage.setItem(test, test) 388 | localStorage.removeItem(test) 389 | return true 390 | } catch (e) { 391 | return false 392 | } 393 | } 394 | 395 | function init() { 396 | var user_ip = getCookie("user_ip") 397 | var ls_user_ip = load_from_ls('user_ip') 398 | if (!user_ip && ls_user_ip) { 399 | setCookie("user_ip", ls_user_ip, {expires: 3600 * 24 * 7}) 400 | } 401 | 402 | 403 | if (station_name) 404 | station_name.value = load_from_ls('station') || '' 405 | 406 | l_map = L.map('mapid', { 407 | fullscreenControl: { 408 | pseudoFullscreen: true // if true, fullscreen to page width and height 409 | } 410 | }).setView([51.6754966, 39.2088823], 13) 411 | 412 | my_renderer = L.canvas({padding: 0.5}); 413 | 414 | L.tileLayer('https://a.tile.openstreetmap.org/{z}/{x}/{y}.png', { 415 | attribution: '© OpenStreetMap contributors', 416 | }).addTo(l_map) 417 | 418 | marker_group = L.layerGroup().addTo(l_map); 419 | 420 | // var myIcon = L.divIcon({className: '', 421 | // html: '' + 422 | // '' + 423 | // '
HELLO
' 424 | // }); 425 | // // you can set .my-div-icon styles in CSS 426 | // L.marker([51.6754966, 39.2088823], {icon: myIcon}).addTo(l_map); 427 | 428 | var iconUrls = [ 429 | 'marker-icon-2x-blue.png', 430 | 'marker-icon-2x-green.png', 431 | 'marker-icon-2x-red.png', 432 | ] 433 | 434 | var base_color_marker_url = 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/' 435 | iconUrls.forEach(function (value) { 436 | icon_urls.push(base_color_marker_url + value) 437 | }) 438 | 439 | if (station_name) { 440 | get_bus_stop_list() 441 | } 442 | } 443 | 444 | document.addEventListener("DOMContentLoaded", init); 445 | })() -------------------------------------------------------------------------------- /data_providers.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | import pathlib 5 | import random 6 | import time 7 | from datetime import datetime 8 | from pathlib import Path 9 | from typing import List, Dict 10 | 11 | import fdb 12 | from firebird.driver import connect, driver_config, transaction, Cursor 13 | 14 | import firebird.driver 15 | 16 | from data_types import CdsRouteBus, CdsBaseDataProvider, CoddBus, LongBusRouteStop, BusStop 17 | 18 | LOAD_TEST_DATA = False 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | if not Path('dumps').exists(): 23 | Path('dumps').mkdir(parents=True) 24 | 25 | try: 26 | import settings 27 | 28 | CDS_HOST = settings.CDS_HOST 29 | CDS_DB_PROJECTS_PATH = settings.CDS_DB_PROJECTS_PATH 30 | CDS_DB_DATA_PATH = settings.CDS_DB_DATA_PATH 31 | CDS_USER = settings.CDS_USER 32 | CDS_PASS = settings.CDS_PASS 33 | LOAD_TEST_DATA = settings.LOAD_TEST_DATA 34 | except ImportError: 35 | settings = None 36 | env = os.environ 37 | if all((x in env for x in ("CDS_HOST", "CDS_DB_PROJECTS_PATH", "CDS_DB_DATA_PATH", "CDS_USER", "CDS_PASS",))): 38 | CDS_HOST = env['CDS_HOST'] 39 | CDS_DB_PROJECTS_PATH = env['CDS_DB_PROJECTS_PATH'] 40 | CDS_DB_DATA_PATH = env['CDS_DB_DATA_PATH'] 41 | CDS_USER = env['CDS_USER'] 42 | CDS_PASS = env['CDS_PASS'] 43 | else: 44 | LOAD_TEST_DATA = True 45 | 46 | 47 | 48 | driver_config.server_defaults.host.value = CDS_HOST 49 | 50 | 51 | def fetch_cursor_map(cur: Cursor): 52 | cursor_mapping = [x[0] for x in cur.description] 53 | all_rows = cur.fetchall() 54 | 55 | return [{k: v for k, v in zip(cursor_mapping, row)} for row in all_rows] 56 | 57 | 58 | def dump_data(data, resource): 59 | fname = pathlib.Path(f'dumps/{resource}.json') 60 | dump_info = json.dumps(data, default=str) 61 | file_content = dump_info.encode('utf-8') 62 | with open(fname, 'wb+') as f: 63 | f.write(file_content) 64 | logger.info(f'new_dump_file {fname}') 65 | 66 | 67 | def load_data(resource): 68 | fname = pathlib.Path(f'dumps/{resource}.json') 69 | if not fname.exists(): 70 | return None 71 | mtime = datetime.fromtimestamp(fname.stat().st_mtime) 72 | mdelta = (datetime.now()-mtime).seconds 73 | if mdelta > 600: 74 | return None 75 | with open(fname, 'rb') as f: 76 | return json.load(f) 77 | 78 | 79 | class CdsDBDataProvider(CdsBaseDataProvider): 80 | CACHE_TIMEOUT = 30 81 | 82 | def __init__(self, logger): 83 | self.logger = logger 84 | self.cds_db_project = connect(database=CDS_DB_PROJECTS_PATH, user=CDS_USER, 85 | password=CDS_PASS, charset='WIN1251') 86 | self.cds_db_data = connect(database=CDS_DB_DATA_PATH, user=CDS_USER, 87 | password=CDS_PASS, charset='WIN1251') 88 | self.cds_db_project.default_tpb = fdb.ISOLATION_LEVEL_READ_COMMITED_RO 89 | self.cds_db_data.default_tpb = fdb.ISOLATION_LEVEL_READ_COMMITED_RO 90 | self.load_obl_objects = False 91 | 92 | def try_reconnect(self): 93 | try: 94 | self.cds_db_project.close() 95 | self.cds_db_project = connect(database=CDS_DB_PROJECTS_PATH, user=CDS_USER, 96 | password=CDS_PASS, charset='WIN1251') 97 | self.cds_db_project.default_tpb = fdb.ISOLATION_LEVEL_READ_COMMITED_RO 98 | self.logger.info(f"Success connect to {CDS_HOST} {CDS_DB_PROJECTS_PATH}") 99 | except Exception as general_error: 100 | self.logger.error(general_error) 101 | 102 | try: 103 | self.cds_db_data.close() 104 | self.cds_db_data = connect(database=CDS_DB_DATA_PATH, user=CDS_USER, 105 | password=CDS_PASS, charset='WIN1251') 106 | self.cds_db_data.default_tpb = fdb.ISOLATION_LEVEL_READ_COMMITED_RO 107 | self.logger.info(f"Success connect to {CDS_HOST} {CDS_DB_DATA_PATH}") 108 | except Exception as general_error: 109 | self.logger.error(general_error) 110 | 111 | def now(self) -> datetime: 112 | return datetime.now() 113 | 114 | def load_codd_route_names(self) -> Dict: 115 | def unpack_data(r): 116 | r = [CoddBus(**x) for x in r] 117 | return {x.NAME_: x for x in r} 118 | 119 | self.logger.debug('Execute fetch routes from DB') 120 | start = time.time() 121 | 122 | cached_result = load_data('codd_route') 123 | if cached_result: 124 | self.logger.info(f"Finish proccess. Elapsed: {time.time() - start:.2f}") 125 | return unpack_data(cached_result) 126 | 127 | try: 128 | with transaction(self.cds_db_project.transaction_manager()) as tr: 129 | cur = tr.cursor() 130 | cur.execute('''select ID_, NAME_, ROUTE_ACTIVE_ from ROUTS 131 | order by NAME_''') 132 | self.logger.debug('Finish execution') 133 | result = fetch_cursor_map(cur) 134 | # tr.commit() 135 | # cur.close() 136 | self.logger.info(f"Finish fetch data. Elapsed: {time.time() - start:.2f}") 137 | except firebird.driver.DatabaseError as db_error: 138 | self.logger.error(db_error) 139 | self.try_reconnect() 140 | return {} 141 | 142 | dump_data(result, 'codd_route') 143 | self.logger.info(f"Finish proccess. Elapsed: {time.time() - start:.2f}") 144 | return unpack_data(result) 145 | 146 | def load_new_codd_route_names(self): 147 | def unpack_data(r): 148 | r = [CoddBus(**x) for x in r] 149 | return {x.NAME_: x.ID_ for x in r} 150 | self.logger.debug('Execute fetch routes from DB') 151 | start = time.time() 152 | cached_result = load_data('new_codd_route') 153 | if cached_result: 154 | self.logger.info(f"Finish proccess. Elapsed: {time.time() - start:.2f}") 155 | return unpack_data(cached_result) 156 | try: 157 | with transaction(self.cds_db_project.transaction_manager()) as tr: 158 | cur = tr.cursor() 159 | cur.execute('''select "Id" as ID_, "Name" as NAME_ from "NewRoute" 160 | where "NewRouteStatusID" <> 3 161 | order by NAME_''') 162 | self.logger.debug('Finish execution') 163 | result = fetch_cursor_map(cur) 164 | self.logger.info(f"Finish fetch data. Elapsed: {time.time() - start:.2f}") 165 | except firebird.driver.DatabaseError as db_error: 166 | self.logger.error(db_error) 167 | self.try_reconnect() 168 | return {} 169 | 170 | dump_data(result, 'new_codd_route') 171 | self.logger.info(f"Finish proccess. Elapsed: {time.time() - start:.2f}") 172 | return unpack_data(result) 173 | 174 | def convert_to_stations_dict(self, bus_routes, bus_stops_data): 175 | bus_routes_ids = {v: k for k, v in bus_routes.items()} 176 | long_bus_stops = [LongBusRouteStop(**x) for x in bus_stops_data] 177 | bus_stations = {} 178 | 179 | for stop in long_bus_stops: 180 | route_name = bus_routes_ids.get(stop.ROUT_, '') 181 | bus_list = bus_stations.get(route_name, []) 182 | bus_list.append(stop) 183 | bus_stations[route_name] = bus_list 184 | 185 | for (k, v) in bus_stations.items(): 186 | v.sort(key=lambda tup: tup.NUMBER_) 187 | 188 | return bus_stations 189 | 190 | def load_bus_stations_routes(self) -> Dict: 191 | def unpack_data(r): 192 | routes_dict = {k: v.ID_ for k, v in self.load_codd_route_names().items()} 193 | return self.convert_to_stations_dict(routes_dict, r) 194 | start = time.time() 195 | 196 | cached_result = load_data('bus_stations_routes') 197 | if cached_result: 198 | self.logger.info(f"Finish proccess. Elapsed: {time.time() - start:.2f}") 199 | return unpack_data(cached_result) 200 | try: 201 | with transaction(self.cds_db_project.transaction_manager()) as tr: 202 | cur = tr.cursor() 203 | cur.execute('''select bsr.NUM as NUMBER_, bs.NAME as NAME_, bs.LAT as LAT_, 204 | bs.LON as LON_, bsr.ROUTE_ID as ROUT_, 0 as CONTROL_, bsr.BS_ID as ID 205 | from ROUTS r 206 | join BS_ROUTE bsr on bsr.ROUTE_ID = r.ID_ 207 | left join BS on bsr.BS_ID = bs.ID''') 208 | bus_stops_data = fetch_cursor_map(cur) 209 | self.logger.info(f"Finish fetch data. Elapsed: {time.time() - start:.2f}") 210 | except firebird.driver.DatabaseError as db_error: 211 | self.logger.error(db_error) 212 | self.try_reconnect() 213 | return {} 214 | 215 | dump_data(bus_stops_data, 'bus_stations_routes') 216 | self.logger.info(f"Finish proccess. Elapsed: {time.time() - start:.2f}") 217 | return unpack_data(bus_stops_data) 218 | 219 | def load_new_bus_stations_routes(self) -> Dict: 220 | start = time.time() 221 | try: 222 | with transaction(self.cds_db_project.transaction_manager()) as tr: 223 | cur = tr.cursor() 224 | cur.execute('''select bsr."Num" as NUMBER_, nbs."Name" as NAME_, nbs."Latitude" as LAT_, 225 | nbs."Longitude" as LON_, 226 | bsr."RouteId" as ROUT_, 0 as CONTROL_, 227 | bsr."BusStationId" as ID 228 | from "NewRoute" r 229 | join "NewBusStationRoute" bsr on bsr."RouteId" = r."Id" 230 | left join "NewBusStation" nbs on bsr."BusStationId" = nbs."Id" 231 | ''') 232 | bus_stops_data = fetch_cursor_map(cur) 233 | end = time.time() 234 | self.logger.info(f"Finish fetch data. Elapsed: {end - start:.2f}") 235 | except firebird.driver.DatabaseError as db_error: 236 | self.logger.error(db_error) 237 | self.try_reconnect() 238 | return {} 239 | 240 | result = self.convert_to_stations_dict(self.load_new_codd_route_names(), bus_stops_data) 241 | return result 242 | 243 | def load_all_cds_buses(self) -> List[CdsRouteBus]: 244 | def make_names_lower(x): 245 | return {k.lower(): v for (k, v) in x.items()} 246 | 247 | self.logger.debug('Execute fetch all from DB') 248 | start = time.time() 249 | 250 | try: 251 | with transaction(self.cds_db_project.transaction_manager()) as tr: 252 | cur = tr.cursor() 253 | cur.execute('''SELECT bs.NAME_ AS BUS_STATION_, rt.NAME_ AS ROUTE_NAME_, o.NAME_, o.OBJ_ID_, o.LAST_TIME_, 254 | o.LAST_LON_, o.LAST_LAT_, o.LAST_SPEED_, o.LAST_STATION_TIME_, o.PROJ_ID_, 255 | coalesce(o.lowfloor, 0) as low_floor, coalesce(o.VEHICLE_TYPE_, 0) as bus_type, 256 | coalesce(obj_output_, 0) as obj_output, 257 | coalesce(azmth_, 0) as azimuth, 258 | coalesce(o."BortName", '') as bort_name 259 | FROM OBJECTS O LEFT JOIN BUS_STATIONS bs 260 | ON o.LAST_ROUT_ = bs.ROUT_ AND o.LAST_STATION_ = bs.NUMBER_ 261 | LEFT JOIN ROUTS rt ON o.LAST_ROUT_ = rt.ID_''') 262 | self.logger.debug('Finish execution') 263 | result = fetch_cursor_map(cur) 264 | end = time.time() 265 | self.logger.info(f"Finish fetch data. Elapsed: {end - start:.2f}") 266 | except (AssertionError, firebird.driver.types.DatabaseError, firebird.driver.DatabaseError) as db_error: 267 | self.logger.error(db_error) 268 | self.try_reconnect() 269 | return [] 270 | 271 | obl_result = [] 272 | try: 273 | if self.load_obl_objects: 274 | with transaction(self.cds_db_data.transaction_manager())as tr: 275 | cur = tr.cursor() 276 | cur.execute('''SELECT bs.NAME AS BUS_STATION_, rt.NAME_ AS ROUTE_NAME_, o.block_number as OBJ_ID_, 277 | CAST(o.block_number as VARCHAR(10)) as NAME_, o.LAST_TIME as LAST_TIME_, 278 | o.LON as LAST_LON_, o.LAT as LAST_LAT_, 0 as LAST_SPEED_, NULL as LAST_STATION_TIME_, NULL as PROJ_ID_, 279 | 0 as low_floor, 280 | 0 as bus_type, 281 | 0 as obj_output, 282 | coalesce(o.azimuth, 0) as azimuth, 283 | coalesce(o."BortName", '') as bort_name 284 | FROM OBL_OBJECTS O LEFT JOIN BS 285 | ON o.bs_id = bs.ID 286 | LEFT JOIN ROUTS rt ON o.route_id = rt.ID_''') 287 | self.logger.debug('Finish execution') 288 | obl_result = fetch_cursor_map(cur) 289 | end = time.time() 290 | self.logger.info(f"Finish fetch data. Elapsed: {end - start:.2f}") 291 | except firebird.driver.DatabaseError as db_error: 292 | if self.load_obl_objects: 293 | self.load_obl_objects = False 294 | self.logger.error(db_error) 295 | self.try_reconnect() 296 | 297 | result = [CdsRouteBus(**make_names_lower(x)) for x in result + obl_result] 298 | result.sort(key=lambda s: s.last_time_, reverse=True) 299 | end = time.time() 300 | self.logger.info(f"Finish proccess. Elapsed: {end - start:.2f}") 301 | return result 302 | 303 | def load_bus_stops(self) -> List[BusStop]: 304 | self.logger.debug('Execute load_bus_stops from DB') 305 | start = time.time() 306 | try: 307 | with transaction(self.cds_db_project.transaction_manager()) as tr: 308 | cur = tr.cursor() 309 | cur.execute('''select distinct ID, NAME as NAME_, LAT as LAT_, LON as LON_, AZMTH 310 | from bs 311 | order by NAME_''') 312 | self.logger.debug('Finish execution') 313 | result = fetch_cursor_map(cur) 314 | end = time.time() 315 | self.logger.info(f"Finish fetch data. Elapsed: {end - start:.2f}") 316 | except firebird.driver.DatabaseError as db_error: 317 | self.logger.error(db_error) 318 | self.try_reconnect() 319 | return [] 320 | 321 | return [BusStop(**x) for x in result] 322 | 323 | 324 | class CdsTestDataProvider(CdsBaseDataProvider): 325 | CACHE_TIMEOUT = 0.0001 326 | 327 | def __init__(self, logger): 328 | self.logger = logger 329 | self.test_data_files = [] 330 | self.test_data_index = 0 331 | self.mocked_now = datetime.now() 332 | self.load_test_data() 333 | 334 | def load_test_data(self): 335 | self.test_data_files = sorted(Path('./test_data/').glob('codd_data_db*.json')) 336 | self.test_data_index = 0 337 | if self.test_data_files: 338 | path = self.test_data_files[0] 339 | self.mocked_now = datetime.strptime(path.name, "codd_data_db%y_%m_%d_%H_%M_%S.json") 340 | else: 341 | raise Exception("Cannot load test data from ./test_data/") 342 | 343 | def load_codd_route_names(self) -> Dict: 344 | my_file = Path("bus_routes_codd.json") 345 | with open(my_file, 'rb') as f: 346 | return json.load(f) 347 | 348 | def now(self): 349 | if self.test_data_files and self.test_data_index >= len(self.test_data_files): 350 | self.test_data_index = 0 351 | path = self.test_data_files[self.test_data_index] 352 | self.mocked_now = datetime.strptime(path.name, "codd_data_db%y_%m_%d_%H_%M_%S.json") 353 | return self.mocked_now 354 | 355 | def next_test_data(self): 356 | if self.test_data_files and self.test_data_index >= len(self.test_data_files): 357 | self.test_data_index = 0 358 | path = self.test_data_files[self.test_data_index] 359 | self.mocked_now = datetime.strptime(path.name, "codd_data_db%y_%m_%d_%H_%M_%S.json") 360 | with open(path, 'rb') as f: 361 | long_bus_stops = [CdsRouteBus.make(*i) for i in json.load(f)] 362 | self.test_data_index += 1 363 | self.logger.info(f'Loaded {path.name}; {self.mocked_now:%H:%M:%S}') 364 | return long_bus_stops 365 | 366 | def load_all_cds_buses(self) -> List[CdsRouteBus]: 367 | return self.next_test_data() 368 | 369 | def load_bus_stations_routes(self) -> Dict: 370 | with open(Path("bus_stations.json"), 'rb') as f: 371 | bus_stations = json.load(f) 372 | 373 | result = {} 374 | for k, v in bus_stations.items(): 375 | route = [LongBusRouteStop(*i) for i in v] 376 | route.sort(key=lambda tup: tup.NUMBER_) 377 | result[k] = route 378 | 379 | return result 380 | 381 | def load_bus_stops(self) -> List[BusStop]: 382 | with open('bus_stops.json', 'rb') as f: 383 | return [BusStop(**i) for i in json.load(f)] 384 | 385 | 386 | class StubDataProvider(CdsBaseDataProvider): 387 | CACHE_TIMEOUT = 0 388 | 389 | def now(self) -> datetime: 390 | return datetime.now() 391 | 392 | def load_all_cds_buses(self) -> List[CdsRouteBus]: 393 | return [] 394 | 395 | def load_codd_route_names(self) -> Dict: 396 | return {} 397 | 398 | def load_bus_stations_routes(self) -> Dict: 399 | return {} 400 | 401 | def load_bus_stops(self) -> List[BusStop]: 402 | return [] 403 | 404 | def get_data_provider(logger): 405 | try: 406 | return CdsTestDataProvider(logger) if LOAD_TEST_DATA else CdsDBDataProvider(logger) 407 | except Exception as ex: 408 | logger.exception(ex) 409 | return StubDataProvider() 410 | 411 | -------------------------------------------------------------------------------- /tgbot.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import datetime 3 | import os 4 | import re 5 | import textwrap 6 | 7 | from apscheduler.schedulers.background import BackgroundScheduler 8 | from telegram import ReplyKeyboardRemove, InlineKeyboardButton, InlineKeyboardMarkup, \ 9 | KeyboardButton, ReplyKeyboardMarkup 10 | from telegram.ext import CommandHandler, CallbackQueryHandler, Filters, MessageHandler, Updater, run_async 11 | 12 | from data_types import UserLoc, ArrivalInfo, StatsData, BusStop 13 | from fotobus_scrapper import fb_links 14 | from helpers import parse_routes, natural_sort_key, grouper, SearchResult, parse_int 15 | from tracking import EventTracker, TgEvent, get_event_by_name 16 | 17 | try: 18 | import settings 19 | 20 | VRNBUSBOT_TOKEN = settings.VRNBUSBOT_TOKEN 21 | PING_HOST = settings.PING_HOST 22 | USERS_TO_INFORM = settings.USERS_TO_INFORM 23 | except ImportError: 24 | env = os.environ 25 | VRNBUSBOT_TOKEN = env.get('VRNBUSBOT_TOKEN') 26 | USERS_TO_INFORM = env.get('USERS_TO_INFORM', "") 27 | PING_HOST = env.get('PING_HOST', 'http://localhost:8088') 28 | 29 | 30 | class BusBot: 31 | def __init__(self, cds, user_settings, logger, tracker: EventTracker): 32 | """Start the bot.""" 33 | self.cds = cds 34 | self.user_settings = user_settings 35 | self.logger = logger 36 | self.tracker = tracker 37 | # Create the EventHandler and pass it your bot's token. 38 | if not VRNBUSBOT_TOKEN: 39 | self.logger.error("The Telegram bot token is empty. Use @BotFather to get your token") 40 | return 41 | self.stats_fail_start = None 42 | self.stats_fail_start_last = None 43 | self.users_to_inform = [int(x.strip()) for x in USERS_TO_INFORM.split(",")] if USERS_TO_INFORM else [] 44 | self.logger.info(f"User to inform in Tg: {self.users_to_inform}") 45 | self.updater = Updater(VRNBUSBOT_TOKEN, request_kwargs={'read_timeout': 10}) 46 | self.bot = self.updater.bot 47 | # Get the dispatcher to register handlers 48 | self.dp = self.updater.dispatcher 49 | 50 | # on different commands - answer in Telegram 51 | self.updater.dispatcher.add_handler(CommandHandler('settings', self.settings, pass_args=True)) 52 | self.updater.dispatcher.add_handler(CallbackQueryHandler(self.settings_button)) 53 | self.dp.add_handler(CommandHandler("start", self.start)) 54 | 55 | self.dp.add_handler(CommandHandler("help", self.helpcmd)) 56 | self.dp.add_handler(CommandHandler("last", self.last_buses, pass_args=True)) 57 | 58 | self.dp.add_handler(CommandHandler("nextbus", self.next_bus_handler, pass_args=True)) 59 | self.dp.add_handler(CommandHandler("fb", self.fb_link_handler, pass_args=True)) 60 | 61 | self.dp.add_handler(CommandHandler("userstats", self.user_stats)) 62 | self.dp.add_handler(CommandHandler("userstatspro", self.user_stats_pro, pass_args=True)) 63 | 64 | self.dp.add_handler(CommandHandler("stats", self.stats)) 65 | self.dp.add_handler(CommandHandler("statspro", self.stats_full)) 66 | # 67 | # # on noncommand i.e message - echo the message on Telegram 68 | 69 | self.dp.add_handler(MessageHandler(Filters.command, self.custom_command)) 70 | self.dp.add_handler(MessageHandler(Filters.text, self.user_input)) 71 | self.dp.add_handler(MessageHandler(Filters.location, self.location)) 72 | # 73 | # # log all errors 74 | self.dp.add_error_handler(self.error) 75 | 76 | self.scheduler = BackgroundScheduler() 77 | self.scheduler.start() 78 | self.scheduler.add_job(self.stats_checking, 'interval', minutes=1) 79 | # Start the Bot 80 | self.updater.start_polling(timeout=30) 81 | self.stats_checking() 82 | 83 | # Run the bot until you press Ctrl-C or the process receives SIGINT, 84 | # SIGTERM or SIGABRT. This should be used most of the time, since 85 | # start_polling() is non-blocking and will stop the bot gracefully. 86 | # updater.idle() 87 | 88 | def track(self, event: TgEvent, update, *params): 89 | user = update.message.from_user 90 | self.tracker.tg(event, user, *params) 91 | 92 | 93 | def broadcast_message(self, text): 94 | if not VRNBUSBOT_TOKEN: 95 | return 96 | now = datetime.datetime.now() 97 | if not (6 <= now.hour < 23): 98 | return 99 | for user_id in self.users_to_inform: 100 | try: 101 | self.bot.send_message(chat_id=user_id, 102 | text=text, 103 | parse_mode='Markdown') 104 | except Exception as e: 105 | self.logger.error(f'Error while sending message to {user_id=}, {e}') 106 | 107 | def stats_checking(self): 108 | def send_msg(text, force=False): 109 | if self.stats_fail_start: 110 | if self.stats_fail_start_last and (now - self.stats_fail_start_last) < datetime.timedelta(minutes=10): 111 | return 112 | self.stats_fail_start_last = now 113 | 114 | for user_id in self.users_to_inform: 115 | self.bot.send_message(chat_id=user_id, 116 | text=text, 117 | parse_mode='Markdown') 118 | 119 | now = datetime.datetime.now() 120 | if not (6 <= now.hour < 23): 121 | return 122 | response = self.cds.get_bus_statistics() 123 | if not response or response.min1 < 10 or response.min10 / response.min60 < 0.5: 124 | if not self.stats_fail_start: 125 | self.stats_fail_start = now 126 | send_msg(f'```\nПроверьте данные после {self.stats_fail_start:%H:%M:%S} \n{response and response.text}\n```') 127 | elif self.stats_fail_start: 128 | msg = f'```\nДанные снова актуальны после {self.stats_fail_start:%H:%M:%S} \n{response and response.text}\n```' 129 | self.stats_fail_start = None 130 | send_msg(msg) 131 | 132 | @run_async 133 | def custom_command(self, bot, update): 134 | command = update.message.text 135 | if command.startswith('/nextbus_'): 136 | match = re.match(r'/nextbus_(\d*)[ ]*(.*)', command) 137 | if match and match.group(1): 138 | id = int(match.group(1)) 139 | bus_stop = self.cds.get_bus_stop_from_id(id) 140 | if bus_stop: 141 | self.track(TgEvent.CUSTOM_CMD, update, command) 142 | self.next_bus_for_bus_stop(update, bus_stop, match.group(2)) 143 | return 144 | 145 | if command.startswith('/fb'): 146 | match = re.match(r'/fb[_]?(\S+)', command) 147 | if match and match.group(1): 148 | bus_name = match.group(1) 149 | self.track(TgEvent.CUSTOM_CMD, update, command) 150 | self.fb_link_show(bus_name, update) 151 | return 152 | 153 | self.track(TgEvent.WRONG_CMD, update, command, "Didn't find") 154 | bot.send_message(chat_id=update.message.chat_id, 155 | text=f"Sorry, I didn't understand that command. {update.message.text}") 156 | 157 | def error(self, _, update, error): 158 | """Log Errors caused by Updates.""" 159 | self.logger.warning('Update "%s" caused error "%s"', update, error) 160 | if update: 161 | update.message.reply_text(f"Update caused error {error}") 162 | 163 | @run_async 164 | def start(self, _, update): 165 | self.track(TgEvent.START, update) 166 | 167 | location_keyboard = KeyboardButton(text="Местоположение", request_location=True) 168 | cancel_button = KeyboardButton(text="Отмена") 169 | custom_keyboard = [[location_keyboard, cancel_button]] 170 | reply_markup = ReplyKeyboardMarkup(custom_keyboard, one_time_keyboard=True) 171 | update.message.reply_text( 172 | "/nextbus имя остановки - ожидаемое время прибытия\n" 173 | "Отправка местоположения - ожидаемое время прибытия для ближайших " 174 | "трёх остановок\n" 175 | "/last номера маршрутов через пробел - последние " 176 | "остановки автобусов\n" 177 | "Свободный ввод - номера маршрутов и расстояние до автобусов " 178 | "(если отправляли местоположение)", 179 | reply_markup=reply_markup) 180 | 181 | @run_async 182 | def helpcmd(self, _, update): 183 | """Send a message when the command /help is issued.""" 184 | user = update.message.from_user 185 | self.track(TgEvent.HELP, update) 186 | update.message.reply_text(""" 187 | /nextbus имя остановки - ожидаемое время прибытия 188 | 189 | /stats - короткая статистика по автобусам онлайн 190 | 191 | /last номера маршрутов через пробел - последние остановки 192 | 193 | /settings [+|-|add|del|all|none|все] номера маршрутов - фильтрация по маршрутам 194 | 195 | Отправка местоположения - ожидаемое время прибытия для ближайших трёх остановок 196 | Свободный ввод - номера маршрутов и расстояние до автобусов (если отправляли местоположение) 197 | 198 | Примеры: 199 | /nextbus памятник славы - выведет прибытие на остановки: 200 | Памятник Славы (Московский проспект в центр), 201 | Памятник славы (Московский проспект из центра), 202 | Памятник славы (ул. Хользунова в центр) 203 | 204 | /last 5а 113кш - выведет последние остановки автобусов на маршрутах 5А и 113КШ 205 | 206 | /settings 27 5а - фильтрует автобусы в остальных командах, оставляя только выбранные 207 | /settings all - выбрать все (эквивалентно /settings none) или 208 | /settings все 209 | 210 | /settings add 104 125 - добавить к фильтру маршруты 104 125 или 211 | /settings + 104 125 212 | 213 | /settings del 37 52 - удалить из фильтра маршруты 37 52 или 214 | /settings - 37 52 215 | """, 216 | reply_markup=ReplyKeyboardRemove()) 217 | 218 | def send_text(self, text, update, **kwargs): 219 | for part in textwrap.wrap(text, 4000, replace_whitespace=False): 220 | update.message.reply_text(part, **kwargs) 221 | 222 | @run_async 223 | def last_buses(self, _, update, args): 224 | """Send a message when the command /last is issued.""" 225 | user = update.message.from_user 226 | 227 | self.track(TgEvent.LAST, update, args) 228 | user_loc = self.user_settings.get(user.id, {}).get('user_loc', None) 229 | route_params = parse_routes(args) 230 | if route_params.all_buses: 231 | update.message.reply_text('Укажите маршруты для вывода') 232 | return 233 | 234 | response = self.cds.bus_request(route_params, user_loc=user_loc) 235 | text = response[0] 236 | self.track(TgEvent.LAST, update, args) 237 | self.logger.debug(f"last_buses. User: {user}; Response {' '.join(text.split())}") 238 | self.send_text(text, update) 239 | 240 | def get_buttons_routes(self, user_routes): 241 | # TODO: too many buttons 242 | routes_list = sorted(list(self.cds.bus_routes.keys()), key=natural_sort_key) 243 | routes_groups = list(grouper(8, routes_list)) 244 | route_btns = [[InlineKeyboardButton('Hide', callback_data='hide')], 245 | [InlineKeyboardButton('All', callback_data='all'), 246 | InlineKeyboardButton('None', callback_data='none')] 247 | ] + [ 248 | [InlineKeyboardButton(f"{x}{'+' if x in user_routes else ''}", callback_data=x) 249 | for x in group if x] 250 | for group in routes_groups] 251 | keyboard = route_btns + [ 252 | ] 253 | return keyboard 254 | 255 | @run_async 256 | def settings(self, _, update, args): 257 | user_id = update.message.from_user.id 258 | settings = self.user_settings.get(user_id, {}) 259 | route_settings = settings.get('route_settings', []) 260 | input_routes = parse_routes(args)[1] 261 | if input_routes: 262 | cmd = input_routes[0].lower() 263 | change_routes = [y for x in input_routes 264 | for y in self.cds.bus_routes.keys() if x.upper() == y.upper()] 265 | if len(input_routes) == 1 and cmd in ('all', 'none', 'все'): 266 | route_settings = [] 267 | elif cmd in ('del', '-'): 268 | route_settings = [x for x in route_settings if x not in change_routes] 269 | elif cmd in ('add', '+'): 270 | route_settings = list(set(route_settings + change_routes)) 271 | else: 272 | route_settings = change_routes 273 | settings['route_settings'] = sorted(route_settings, key=natural_sort_key) 274 | self.user_settings[user_id] = settings 275 | update.message.reply_text(f"Текущие маршруты для вывода: {' '.join(route_settings)}") 276 | return 277 | 278 | keyboard = self.get_buttons_routes(route_settings) 279 | reply_markup = InlineKeyboardMarkup(keyboard) 280 | 281 | update.message.reply_text('Укажите маршруты для вывода:', reply_markup=reply_markup) 282 | 283 | @run_async 284 | def settings_button(self, bot, update): 285 | query = update.callback_query 286 | self.logger.info(query) 287 | user_id = query.message.chat_id 288 | user_settings = self.user_settings.get(user_id, {}) 289 | settings = user_settings.get('route_settings', []) 290 | key = query.data 291 | 292 | if key == 'all': 293 | settings = list(self.cds.bus_routes.keys()) 294 | elif key == 'none': 295 | settings = [] 296 | elif key == 'hide': 297 | routes = ' '.join(settings) if settings else 'все доступные' 298 | bot.edit_message_text(text=f"Текущие маршруты для вывода: {routes}", 299 | chat_id=query.message.chat_id, 300 | message_id=query.message.message_id) 301 | return 302 | else: 303 | if key in settings: 304 | settings.remove(key) 305 | else: 306 | settings.append(key) 307 | 308 | user_settings['route_settings'] = settings 309 | self.user_settings[user_id] = user_settings 310 | keyboard = self.get_buttons_routes(settings) 311 | reply_markup = InlineKeyboardMarkup(keyboard) 312 | routes = ' '.join(settings) if settings else 'все доступные' 313 | bot.edit_message_text(text=f"Текущие маршруты для вывода: {routes}", 314 | chat_id=query.message.chat_id, 315 | message_id=query.message.message_id, 316 | reply_markup=reply_markup) 317 | 318 | def get_text_from_arrival_info(self, arrival_info: ArrivalInfo): 319 | def text_for_arrival_info(value): 320 | s = (f'```{value.text}```' if value else '') 321 | command = f'/nextbus_{value.bus_stop_id}' 322 | return f"[{command}]({command}) {value.bus_stop_name}\n{s} " 323 | 324 | def text_for_bus_stop(value: BusStop): 325 | command = f'/nextbus_{value.ID}' 326 | return f"[{command}]({command}) {value.NAME_}" 327 | 328 | if arrival_info.found: 329 | next_bus_text = '\n'.join([text_for_arrival_info(v) for v in arrival_info.arrival_details]) 330 | else: 331 | next_bus_text = '\n'.join([text_for_bus_stop(v) for v in arrival_info.bus_stops]) 332 | return f'{arrival_info.header}\n{next_bus_text}' 333 | 334 | @run_async 335 | def next_bus_general(self, update, args): 336 | user = update.message.from_user 337 | 338 | self.track(TgEvent.NEXT, update, args) 339 | if not args: 340 | location_btn = KeyboardButton(text="Местоположение", request_location=True) 341 | cancel_btn = KeyboardButton(text="Отмена") 342 | custom_keyboard = [[location_btn, cancel_btn]] 343 | reply_markup = ReplyKeyboardMarkup(custom_keyboard, one_time_keyboard=True) 344 | update.message.reply_text("""Не указана остановка, попробуйте указать местоположение""", 345 | reply_markup=reply_markup) 346 | return 347 | 348 | user_settings = self.user_settings.get(user.id, {}) 349 | search_result = SearchResult(bus_routes=tuple(user_settings.get('route_settings', []))) 350 | bus_stop_name = args if isinstance(args, str) else ' '.join(args) 351 | response = self.cds.next_bus(bus_stop_name, search_result) 352 | update.message.reply_text(self.get_text_from_arrival_info(response), parse_mode='Markdown') 353 | 354 | def next_bus_for_bus_stop(self, update, bus_stop, params): 355 | user = update.message.from_user 356 | 357 | search_params = parse_routes(params) 358 | 359 | self.track(TgEvent.NEXT, update, bus_stop, params) 360 | 361 | response = self.cds.next_bus_for_matches((bus_stop,), search_params) 362 | update.message.reply_text(self.get_text_from_arrival_info(response), parse_mode='Markdown') 363 | 364 | def next_bus_handler(self, _, update, args): 365 | self.next_bus_general(update, args) 366 | 367 | def fb_link_handler(self, _, update, args): 368 | bus_name = args if isinstance(args, str) else ' '.join(args) 369 | self.fb_link_show(bus_name, update) 370 | 371 | def fb_link_show(self, bus_name, update): 372 | fotobus_links = fb_links(bus_name) 373 | command = f'/fb_{bus_name}' 374 | update.message.reply_text("\n".join((f"[{command}]({command}) [{link}]({link})" for link in fotobus_links)), parse_mode='Markdown') 375 | 376 | @run_async 377 | def send_stats(self, update, full_info): 378 | user = update.message.from_user 379 | 380 | self.track(TgEvent.STATS, update, full_info) 381 | response = self.cds.get_bus_statistics(full_info) or StatsData(0, 0, 0, 0, 0, "Нет данных") 382 | update.message.reply_text(f'/stats ```\n{response.text}\n```', parse_mode='Markdown') 383 | 384 | def stats(self, _, update): 385 | self.send_stats(update, False) 386 | 387 | def stats_full(self, _, update): 388 | self.send_stats(update, True) 389 | 390 | @run_async 391 | def user_stats(self, _, update): 392 | self.track(TgEvent.USER_STATS, update) 393 | stats = self.tracker.stats() 394 | self.logger.debug(stats) 395 | update.message.reply_text(f'```\n{stats}\n```', 396 | parse_mode='Markdown') 397 | 398 | @run_async 399 | def user_stats_pro(self, bot, update, args): 400 | if update.message.from_user.id not in self.users_to_inform: 401 | self.logger.error(f"Unknown user {update.message.from_user}") 402 | return 403 | self.track(TgEvent.USER_STATS, update) 404 | threshold, valid_threshold = parse_int(args[:1], 50) 405 | user_filter = ''.join(args if not valid_threshold else args[1:]) 406 | event_filter = [get_event_by_name(i) for i in args if get_event_by_name(i)] 407 | stats = self.tracker.stats(True, threshold, user_filter, event_filter) 408 | self.send_text(f'```\n{stats}\n```', update, 409 | parse_mode='Markdown') 410 | 411 | @run_async 412 | def user_input(self, bot, update): 413 | message = update.message 414 | user = message.from_user 415 | text = message.text 416 | l_text = text.lower() 417 | 418 | self.track(TgEvent.USER_INPUT, update, text[:30]) 419 | if not text or text == 'Отмена': 420 | message.reply_text(text=f"Попробуйте воспользоваться справкой /help", 421 | reply_markup=ReplyKeyboardRemove()) 422 | return 423 | 424 | if l_text == 'на рефакторинг!': 425 | message.reply_text('Тогда срочно сюда @deeprefactoring!') 426 | return 427 | 428 | if self.cds.is_bus_stop_name(text): 429 | self.next_bus_general(update, text.split(' ')) 430 | return 431 | 432 | if l_text.startswith("ост") or l_text.startswith("аст"): 433 | args = text.lower().split(' ')[1:] 434 | self.next_bus_general(update, args) 435 | return 436 | 437 | match = re.search('https://maps\.google\.com/maps\?.*&ll=(?P[-?\d.]*),(?P[-?\d.]*)', text) 438 | if match: 439 | (lat, lon) = (match.group('lat'), match.group('lon')) 440 | self.show_arrival(update, float(lat), float(lon)) 441 | else: 442 | user_loc = self.user_settings.get(user.id, {}).get('user_loc', None) 443 | self.logger.info(f"User: {user} '{text}' {user_loc}") 444 | route_params = parse_routes(text) 445 | if route_params.all_buses: 446 | update.message.reply_text('Укажите маршруты для вывода') 447 | return 448 | response = self.cds.bus_request(route_params, user_loc=user_loc) 449 | self.logger.debug(f'"{text}" User: {user}; Response: {response[:5]} from {len(response)}') 450 | reply_text = response[0] 451 | for part in textwrap.wrap(reply_text, 4000, replace_whitespace=False): 452 | update.message.reply_text(part, reply_markup=ReplyKeyboardRemove()) 453 | 454 | def show_arrival(self, update, lat, lon): 455 | user = update.message.from_user 456 | 457 | self.track(TgEvent.NEXT, update, lat, lon) 458 | self.logger.info(f"User: {user} {lat}, {lon}") 459 | matches = self.cds.matches_bus_stops(lat, lon) 460 | user_loc = UserLoc(lat, lon) 461 | settings = self.user_settings.get(user.id, {}) 462 | settings['user_loc'] = user_loc 463 | self.user_settings[user.id] = settings 464 | bus_routes = settings.get('route_settings') 465 | search_result = SearchResult(bus_routes=(bus_routes if bus_routes else tuple())) 466 | arrival_info = self.cds.next_bus_for_matches(tuple(matches), search_result) 467 | self.logger.debug(f"next_bus_for_matches {user} {arrival_info}") 468 | update.message.reply_text(self.get_text_from_arrival_info(arrival_info), parse_mode='Markdown', 469 | reply_markup=ReplyKeyboardRemove()) 470 | 471 | @run_async 472 | def location(self, _, update): 473 | loc = update.message.location 474 | (lat, lon) = loc.latitude, loc.longitude 475 | self.show_arrival(update, lat, lon) 476 | -------------------------------------------------------------------------------- /fe/map.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | if (location.protocol !== 'https:' && location.hostname !== 'localhost') { 5 | location.href = 'https:' + window.location.href.substring(window.location.protocol.length); 6 | } 7 | 8 | var coords = {latitude: 51.6754966, longitude: 39.2088823} 9 | 10 | var lastbusquery = document.getElementById('lastbusquery') 11 | var station_query = document.getElementById('station_query') 12 | var station_name = document.getElementById('station_name') 13 | var my_map 14 | var BusIconContentLayout 15 | var timer_id = 0 16 | 17 | var info = document.getElementById('info') 18 | var businfo = document.getElementById('businfo') 19 | var lastbus_map_update = document.getElementById('lastbus_map_update') 20 | var nextbus_loading = document.getElementById('nextbus_loading') 21 | var lastbus_loading = document.getElementById('lastbus_loading') 22 | var cb_refresh = document.getElementById('cb_refresh') 23 | var cb_show_info = document.getElementById('cb_show_info') 24 | var btn_station_search = document.getElementById('btn_station_search') 25 | 26 | var bus_stop_list = [] 27 | var bus_stop_names = [] 28 | var bus_stop_auto_complete 29 | 30 | if (lastbus_map_update) 31 | lastbus_map_update.onclick = function () { 32 | update_bus_map() 33 | } 34 | 35 | if (cb_refresh) 36 | cb_refresh.onclick = function () { 37 | if (!cb_refresh.checked) { 38 | clearTimeout(timer_id) 39 | clearTimeout(timer_stop_id) 40 | timer_id = 0 41 | timer_stop_id = 0 42 | } 43 | } 44 | 45 | if (cb_show_info) { 46 | cb_show_info.onclick = function () { 47 | var show = cb_show_info.checked 48 | businfo.className = show ? "" : "hide_info" 49 | } 50 | } 51 | 52 | if (btn_station_search) { 53 | btn_station_search.onclick = function () { 54 | run_search_by_name() 55 | } 56 | } 57 | if (lastbusquery) { 58 | lastbusquery.onkeyup = function (event) { 59 | event.preventDefault() 60 | if (event.keyCode === 13) { 61 | update_bus_map() 62 | } 63 | } 64 | } 65 | 66 | if (station_query) { 67 | station_query.onkeyup = function (event) { 68 | event.preventDefault() 69 | if (event.keyCode === 13) { 70 | run_search_by_name() 71 | } 72 | } 73 | } 74 | 75 | if (station_name) { 76 | station_name.onkeyup = function (event) { 77 | event.preventDefault() 78 | if (event.keyCode === 13) { 79 | run_search_by_name() 80 | } 81 | } 82 | } 83 | 84 | function set_query_parameter(name, value) { 85 | const params = new URLSearchParams(window.location.search); 86 | params.set(name, value); 87 | window.history.replaceState({}, "", decodeURIComponent(`${window.location.pathname}?${params}`)); 88 | } 89 | 90 | function run_timer(func) { 91 | if (cb_refresh.checked && !timer_id) { 92 | timer_id = setTimeout(function tick() { 93 | func().then(function () { 94 | if (cb_refresh.checked) 95 | timer_id = setTimeout(tick, 30 * 1000) 96 | }) 97 | }, 30 * 1000) 98 | } 99 | } 100 | 101 | function run_search_by_name() { 102 | run_timer(run_search_by_name) 103 | 104 | update_user_position() 105 | return get_bus_arrival_by_name() 106 | } 107 | 108 | function update_bus_map() { 109 | run_timer(update_bus_map) 110 | 111 | var bus_query = lastbusquery.value 112 | save_to_ls('bus_query', bus_query) 113 | update_user_position() 114 | return get_bus_positions(bus_query) 115 | } 116 | 117 | if ("geolocation" in navigator) { 118 | var nextbus = document.getElementById('nextbus') 119 | 120 | if (nextbus) 121 | nextbus.onclick = function (event) { 122 | event.preventDefault() 123 | get_current_pos(get_bus_arrival) 124 | } 125 | } 126 | 127 | function update_user_position() { 128 | if ("geolocation" in navigator) { 129 | navigator.geolocation.getCurrentPosition(function (position) { 130 | coords = position.coords 131 | }) 132 | } 133 | } 134 | 135 | function save_station_params() { 136 | var query = station_query.value 137 | var station = station_name.value 138 | 139 | save_to_ls('station_query', query) 140 | save_to_ls('station', station) 141 | } 142 | 143 | function get_current_pos(func) { 144 | save_station_params() 145 | 146 | update_user_position() 147 | } 148 | 149 | function format_bus_stops(header, bus_stops) { 150 | var bus_stop_info = header + '\n' 151 | for (var prop in bus_stops) { 152 | bus_stop_info += '' + prop + '' + '\n' + bus_stops[prop] + '\n' 153 | } 154 | 155 | info.innerHTML = bus_stop_info 156 | var elements = document.getElementsByClassName('bus_linked') 157 | for (var i = 0; i < elements.length; i++) { 158 | elements[i].onclick = function (e) { 159 | e.preventDefault() 160 | if (e.srcElement && e.srcElement.text) { 161 | station_name.value = e.srcElement.text 162 | get_bus_arrival_by_name() 163 | } 164 | } 165 | } 166 | } 167 | 168 | function get_bus_arrival_by_name() { 169 | var btn_station_search = document.getElementById('btn_station_search') 170 | waiting(nextbus_loading, btn_station_search, true) 171 | 172 | var bus_query = station_query.value 173 | var station = station_name.value 174 | 175 | save_station_params() 176 | 177 | var params = 'q=' + encodeURIComponent(bus_query) + 178 | '&station=' + encodeURIComponent(station) 179 | 180 | return fetch('/bus_stop_search?' + params, 181 | { 182 | method: 'GET', 183 | headers: { 184 | 'Content-Type': 'application/json' 185 | }, 186 | credentials: 'include', 187 | }) 188 | .then(function (res) { 189 | return res.json() 190 | }) 191 | .then(function (data) { 192 | update_cookies() 193 | waiting(nextbus_loading, btn_station_search, false) 194 | format_bus_stops(data.header, data.bus_stops) 195 | }) 196 | .catch(function (error) { 197 | waiting(nextbus_loading, btn_station_search, false) 198 | info.innerHTML = 'Ошибка: ' + error 199 | }) 200 | } 201 | 202 | function get_bus_arrival(position) { 203 | var nextbus = document.getElementById('nextbus') 204 | waiting(nextbus_loading, nextbus, true) 205 | 206 | coords = position.coords 207 | var bus_query = station_query.value 208 | 209 | var params = 'q=' + encodeURIComponent(bus_query) + 210 | '&lat=' + encodeURIComponent(coords.latitude) + 211 | '&lon=' + encodeURIComponent(coords.longitude) 212 | 213 | return fetch('/arrival?' + params, 214 | { 215 | method: 'GET', 216 | headers: { 217 | 'Content-Type': 'application/json' 218 | }, 219 | credentials: 'include', 220 | }) 221 | .then(function (res) { 222 | return res.json() 223 | }) 224 | .then(function (data) { 225 | update_cookies() 226 | waiting(nextbus_loading, nextbus, false) 227 | format_bus_stops(data.header, data.bus_stops) 228 | }) 229 | .catch(function (error) { 230 | waiting(nextbus_loading, nextbus, false) 231 | info.innerHTML = 'Ошибка: ' + error 232 | }) 233 | } 234 | 235 | 236 | function waiting(element, button, state) { 237 | element.className = state ? 'spinner' : '' 238 | button.disabled = state 239 | } 240 | 241 | function fraud_check() { 242 | if (parent !== window) { 243 | return "&parentUrl=" + encodeURIComponent(document.referrer) 244 | } 245 | return "" 246 | } 247 | 248 | function diff_time(last_time, max_time) { 249 | var date_1 = new Date(last_time) 250 | var date_2 = new Date(max_time) 251 | 252 | return (date_2 - date_1)/1000; 253 | } 254 | 255 | function formate_date(last_time) { 256 | function pad_zero(number) { 257 | return ('0' + number).slice(-2) 258 | } 259 | 260 | var date = new Date(last_time) 261 | var time = last_time.substring(last_time.length - 8) 262 | if (Date.now() - date > (3600 * 1000 * 24)) { 263 | time = date.getFullYear() + '-' + pad_zero(date.getMonth() + 1) + '-' + pad_zero(date.getDate()) + ' ' + time 264 | } 265 | 266 | return time 267 | } 268 | 269 | function get_bus_positions(query) { 270 | waiting(lastbus_loading, lastbus_map_update, true) 271 | set_query_parameter('bus_query', query) 272 | var params = 'src=map&q=' + encodeURIComponent(query) + fraud_check() 273 | if (coords) { 274 | params += '&lat=' + encodeURIComponent(coords.latitude) 275 | params += '&lon=' + encodeURIComponent(coords.longitude) 276 | } 277 | 278 | return fetch('/busmap?' + params, 279 | { 280 | method: 'GET', 281 | headers: { 282 | 'Content-Type': 'application/json' 283 | }, 284 | credentials: 'include', 285 | }) 286 | .then(function (res) { 287 | return res.json() 288 | }) 289 | .then(function (data) { 290 | update_cookies() 291 | waiting(lastbus_loading, lastbus_map_update, false) 292 | var q = data.q 293 | var text = data.text 294 | var server_time = new Date(data.server_time) 295 | businfo.innerHTML = 'Маршруты: ' + q + '\nКоличество результатов: ' + data.buses.length + '\n' + text 296 | 297 | if (!my_map) 298 | return 299 | 300 | if (data.buses.length > 0) { 301 | var min_last_time = data.buses[0][0].last_time_; 302 | var max_time = data.buses.reduce(function (accumulator, currentValue) { 303 | if (currentValue[0].last_time_ > accumulator) 304 | return currentValue[0].last_time_; 305 | return accumulator; 306 | }, min_last_time); 307 | 308 | var delta_with_current = (new Date() - new Date(max_time))/1000 309 | var delta_with_max = (server_time - new Date(max_time))/1000 310 | // 311 | // console.log("current_time - max_time", delta_with_current.toFixed(1)) 312 | // console.log("server_time - max_time", delta_with_max.toFixed(1)) 313 | } 314 | 315 | var bus_with_azimuth = data.buses.map(function (data) { 316 | var bus = data[0] 317 | var next_bus_stop = data[1] 318 | if (!next_bus_stop.LON_ || !next_bus_stop.LAT_) { 319 | return bus 320 | } 321 | 322 | bus.hint = next_bus_stop.NAME_ 323 | 324 | var x = next_bus_stop.LAT_ - bus.last_lat_ 325 | var y = next_bus_stop.LON_ - bus.last_lon_ 326 | 327 | bus.db_azimuth = bus.azimuth 328 | bus.azimuth = Math.floor(Math.atan2(y, x) * 180 / Math.PI) 329 | var time = formate_date(bus.last_time_) 330 | var bus_type = "МВ" 331 | switch (bus.bus_type) { 332 | case 3: 333 | bus_type = "СВ" 334 | break 335 | case 4: 336 | bus_type = "БВ" 337 | break 338 | } 339 | var bus_output = bus.obj_output === 1 ? ' ! ' : '' 340 | 341 | bus.delta_time = diff_time(bus.last_time_, server_time); 342 | var show_bus_name = bus.name_ && !bus.hidden_name 343 | var bus_name = show_bus_name ? bus.name_ : bus.hidden_name; 344 | var route_name = bus.route_name_.trim(); 345 | var fb_link_info = " " + route_name + " " + bus.name_ + ""; 346 | 347 | bus.desc = [bus_output + time + " " + next_bus_stop.NAME_, 348 | (show_bus_name ? fb_link_info : route_name) + " " + bus.bort_name, 349 | bus.last_speed_.toFixed(1) 350 | + " ~ " + bus.avg_speed.toFixed(1) 351 | + " ~ " + bus.avg_last_speed.toFixed(1) + ' км/ч', 352 | "Азимуты: к остановке " + bus.azimuth + '; ' + bus.db_azimuth, 353 | (bus.low_floor ? "Низкопол" : "") + " " + bus_type, 354 | 'Отправить обращение', 355 | 356 | ].join('
') 357 | 358 | 359 | return bus 360 | }) 361 | update_map(bus_with_azimuth, true) 362 | }).catch(function (error) { 363 | waiting(lastbus_loading, lastbus_map_update, false) 364 | console.error(error) 365 | businfo.innerHTML = 'Ошибка: ' + error 366 | }) 367 | } 368 | 369 | function get_bus_stop_list() { 370 | return fetch('/bus_stops.json', 371 | { 372 | method: 'GET', 373 | headers: { 374 | 'Content-Type': 'application/json' 375 | }, 376 | credentials: 'include', 377 | }) 378 | .then(function (res) { 379 | return res.json() 380 | }) 381 | .then(function (data) { 382 | update_cookies() 383 | bus_stop_list = data 384 | bus_stop_names = bus_stop_list.map(function callback(bus_stop) { 385 | return bus_stop.NAME_ 386 | }) 387 | if (station_name) { 388 | bus_stop_auto_complete = new autoComplete({ 389 | selector: station_name, 390 | source: function (term, suggest) { 391 | term = term.toLowerCase(); 392 | var matches = []; 393 | for (var i = 0; i < bus_stop_names.length; i++) 394 | if (~bus_stop_names[i].toLowerCase().indexOf(term)) matches.push(bus_stop_names[i]); 395 | suggest(matches); 396 | } 397 | }) 398 | } 399 | 400 | }) 401 | } 402 | 403 | 404 | function get_bus_list() { 405 | return fetch('/buslist', 406 | { 407 | method: 'GET', 408 | headers: { 409 | 'Content-Type': 'application/json' 410 | }, 411 | credentials: 'include', 412 | }) 413 | .then(function (res) { 414 | return res.json() 415 | }) 416 | .then(function (data) { 417 | update_cookies() 418 | var bus_list = data.result 419 | 420 | var select = document.getElementById('buslist') 421 | select.appendChild(new Option('Маршруты', '-')) 422 | bus_list.forEach(function (bus_name) { 423 | var opt = new Option(bus_name, bus_name) 424 | select.appendChild(opt) 425 | }) 426 | 427 | select.onchange = function () { 428 | var text = select.options[select.selectedIndex].value; // Текстовое значение для выбранного option 429 | if (text !== '-') { 430 | if (lastbusquery) 431 | lastbusquery.value += ' ' + text 432 | if (station_query) 433 | station_query.value += ' ' + text 434 | } 435 | } 436 | }) 437 | } 438 | 439 | function update_map(buses, clear) { 440 | if (!my_map) { 441 | return 442 | } 443 | 444 | var objectManager = new ymaps.ObjectManager() 445 | 446 | objectManager.objects.options.set({ 447 | iconLayout: 'default#imageWithContent', 448 | iconImageHref: 'bus_round_copy.png', 449 | iconImageSize: [32, 32], 450 | iconImageOffset: [-16, -16], 451 | iconContentOffset: [0, 0], 452 | iconContentLayout: BusIconContentLayout, 453 | balloonMaxWidth: 250, 454 | }) 455 | 456 | var features = [] 457 | 458 | 459 | if (clear) { 460 | my_map.geoObjects.removeAll() 461 | } 462 | 463 | 464 | buses.forEach(function (bus, index) { 465 | features.push(add_bus(bus, index)) 466 | }) 467 | 468 | objectManager.add({ 469 | "type": "FeatureCollection", 470 | "features": features 471 | }) 472 | 473 | my_map.geoObjects.add(objectManager) 474 | } 475 | 476 | function add_stop(stop, id) { 477 | if (!stop) { 478 | return 479 | } 480 | var hint_content = stop.NAME_ 481 | var balloon_content = stop.NAME_ 482 | var lat = stop.LAT_ 483 | var lon = stop.LON_ 484 | 485 | return { 486 | "type": "Feature", 487 | "id": id, 488 | "geometry": {"type": "Point", "coordinates": [lat, lon]}, 489 | "properties": { 490 | "balloonContent": balloon_content, 491 | "hintContent": hint_content, 492 | "clusterCaption": hint_content 493 | } 494 | } 495 | } 496 | 497 | 498 | function add_bus(bus, id, max_time) { 499 | if (!bus) { 500 | return 501 | } 502 | var hint_content = bus.hint ? bus.hint : bus.last_time_ + '; ' + bus.azimuth 503 | var balloon_content = bus.desc ? bus.desc : bus.last_time_ + JSON.stringify(bus, null, ' ') 504 | var lat = bus.lat2 || bus.last_lat_ 505 | var lon = bus.lon2 || bus.last_lon_ 506 | var bus_output = bus.obj_output === 1 ? ' !' : '' 507 | var show_bus_name = bus.name_ && !bus.hidden_name 508 | 509 | var icon_content = bus_output + " " + bus.route_name_.trim() + (show_bus_name ? " " + bus.name_ : "") 510 | var rotation = bus.db_azimuth 511 | var wait = bus.delta_time < 60 ? '' : '_wait'; 512 | if (bus.delta_time > 180){ 513 | wait = '_long_wait' 514 | } 515 | var file_name = bus.low_floor === 1 ? 'bus_round_lf' : 'bus_round'; 516 | 517 | return { 518 | "type": "Feature", 519 | "id": id, 520 | "geometry": {"type": "Point", "coordinates": [lat, lon]}, 521 | "properties": { 522 | "balloonContent": balloon_content, 523 | "hintContent": hint_content, 524 | "iconContent": icon_content, 525 | "rotation": rotation, 526 | "clusterCaption": icon_content + ' ' + hint_content, 527 | 'iconImageHref': file_name + wait +'.png', 528 | }, 529 | "options": { 530 | iconImageHref: "img/" + file_name + wait +'.png', 531 | } 532 | } 533 | } 534 | 535 | if ('ymaps' in window) { 536 | ymaps.ready(ymap_show); 537 | } 538 | 539 | function add_bus_stops(stops) { 540 | var objectManager = new ymaps.ObjectManager({ 541 | clusterize: true, 542 | gridSize: 80, 543 | clusterDisableClickZoom: true 544 | }) 545 | 546 | objectManager.objects.options.set({ 547 | preset: 'islands#darkGreenCircleDotIcon' 548 | }) 549 | 550 | var features = [] 551 | 552 | stops.forEach(function (stop, index) { 553 | features.push(add_stop(stop, index)) 554 | }) 555 | 556 | objectManager.add({ 557 | "type": "FeatureCollection", 558 | "features": features 559 | }) 560 | 561 | my_map.events.add('click', function (e) { 562 | my_map.balloon.open(e.get('coords'), 'Щелк!'); 563 | e.stopPropagation() 564 | e.preventDefault(); 565 | }); 566 | my_map.geoObjects.add(objectManager) 567 | } 568 | 569 | function ymap_show() { 570 | var map_zoom = load_from_ls('map_zoom') || 13 571 | var map_lat = load_from_ls('map_lat') || coords.latitude 572 | var map_lon = load_from_ls('map_lon') || coords.longitude 573 | 574 | my_map = new ymaps.Map('map', { 575 | center: [map_lat, map_lon], 576 | zoom: map_zoom 577 | }, { 578 | searchControlProvider: 'yandex#search', 579 | minZoom: 10, 580 | maxZoom: 19 581 | }) 582 | 583 | my_map.events.add('boundschange', function (event) { 584 | save_to_ls('map_zoom', event.get('newZoom')) 585 | var center = event.get('newCenter') 586 | 587 | save_to_ls('map_lat', center[0]) 588 | save_to_ls('map_lon', center[1]) 589 | }); 590 | 591 | BusIconContentLayout = ymaps.templateLayoutFactory.createClass( 592 | '' + 593 | ' $[properties.iconContent] ' 594 | ) 595 | 596 | // TODO: Check with buses 597 | // add_bus_stops(bus_stop_list) 598 | if (location.hostname === "localhost" || location.hostname === "127.0.0.1") { 599 | } 600 | update_bus_map() 601 | 602 | } 603 | 604 | function save_to_ls(key, value) { 605 | if (!ls_test()) { 606 | return 607 | } 608 | localStorage.setItem(key, value) 609 | } 610 | 611 | function load_from_ls(key) { 612 | if (!ls_test()) { 613 | return 614 | } 615 | return localStorage.getItem(key) 616 | } 617 | 618 | function ls_test() { 619 | var test = 'test' 620 | if (!'localStorage' in window) { 621 | return false 622 | } 623 | try { 624 | localStorage.setItem(test, test) 625 | localStorage.removeItem(test) 626 | return true 627 | } catch (e) { 628 | return false 629 | } 630 | } 631 | 632 | function setCookie(name, value, options) { 633 | options = options || {}; 634 | 635 | var expires = options.expires; 636 | 637 | if (typeof expires == "number" && expires) { 638 | var d = new Date(); 639 | d.setTime(d.getTime() + expires * 1000); 640 | expires = options.expires = d; 641 | } 642 | if (expires && expires.toUTCString) { 643 | options.expires = expires.toUTCString(); 644 | } 645 | 646 | value = encodeURIComponent(value); 647 | 648 | var updatedCookie = name + "=" + value; 649 | 650 | for (var propName in options) { 651 | updatedCookie += "; " + propName; 652 | var propValue = options[propName]; 653 | if (propValue !== true) { 654 | updatedCookie += "=" + propValue; 655 | } 656 | } 657 | 658 | document.cookie = updatedCookie; 659 | } 660 | 661 | function getCookie(name) { 662 | var matches = document.cookie.match(new RegExp( 663 | "(?:^|; )" + name.replace(/([\.$?*|{}\(\)\[\]\\\/\+^])/g, '\\$1') + "=([^;]*)" 664 | )); 665 | return matches ? decodeURIComponent(matches[1]) : undefined; 666 | } 667 | 668 | 669 | function update_cookies() { 670 | var user_ip = getCookie("user_ip") 671 | if (user_ip) { 672 | save_to_ls("user_ip", user_ip) 673 | } 674 | 675 | var ls_user_ip = load_from_ls('user_ip') 676 | if (!user_ip && ls_user_ip) { 677 | setCookie("user_ip", ls_user_ip, {expires: 3600 * 24 * 7}) 678 | } 679 | } 680 | 681 | 682 | function init() { 683 | update_cookies() 684 | if (station_name) { 685 | get_bus_stop_list() 686 | } 687 | get_bus_list() 688 | 689 | const params = new URLSearchParams(window.location.search); 690 | const bus_query = params.get('bus_query') 691 | 692 | if (lastbusquery) { 693 | lastbusquery.value = params.get('bus_query') || load_from_ls('bus_query') || '' 694 | } 695 | 696 | if (station_query) 697 | station_query.value = load_from_ls('station_query') || '' 698 | 699 | if (station_name) 700 | station_name.value = load_from_ls('station') || '' 701 | } 702 | 703 | document.addEventListener("DOMContentLoaded", init); 704 | })() --------------------------------------------------------------------------------