Правильные графики Covid-19
Во времена повсеместной одержимости библиотеками и веб-фреймворками мы стали забывать радость от решения задач минимальными средствами. В этой статье, мы запилим веб-сервис на актуальную тему, используя ванильные Python и JavaScript, а также, задеплоим его в GitLab Pages. Быстро, минималистично, без лишних зависимостей, и максимально элегантно.
Вдохновившись видосом How To Tell If We’re Beating COVID-19 от minutephysics, я набросал в свободное (от удаленной работы и домашних дел) время сервис, который на основе данных с Карты распространения коронавируса в России и мире от Яндекса строит графики, аналогичные тем, что на странице Covid Trends. Вот, что из этого вышло:
Интересно? Погнали!
Где взять данные?
Примерно в то время, когда у меня родилась идея воспроизвести графики от minutephysics для регионов России, Яндекс добавил в карту гистограммы по каждому региону.
Данные хранятся прямо в теле страницы, что оказалось крайне удобно. Я испытываю какое-то особое удовольствие, когда получается элегантно решить задачу без внешних зависимостей, так что, для такого простого случая как наш, монструозные парсеры и библиотека requests идут лесом. Только встроенные средства языка, только хардкор (не такой уж и хардкор, в стандартной библиотеке есть всё, что надо, и всё крайне удобно):
from urllib.request import urlopen
from html.parser import HTMLParser
import json
class Covid19DataLoader(HTMLParser):
page_url = "https://yandex.ru/web-maps/covid19"
def __init__(self):
super().__init__()
self.config_found = False
self.config = None
def load(self):
with urlopen(self.page_url) as response:
page = response.read().decode("utf8")
self.feed(page)
return self.config['covidData']
def handle_starttag(self, tag, attrs):
if tag == 'script':
for k, v in attrs:
if k == 'class' and v == 'config-view':
self.config_found = True
def handle_data(self, data):
if self.config_found and not self.config:
self.config = json.loads(data)
Как поместить данные на страницу?
Данные получены, следующая задача — поместить их в нужное место HTML-странички, чтобы показать через Chart.js. В веб-фреймворках для этого используются шаблонизаторы, но нам не нужны никакие шаблонизаторы кроме string.Template:
def get_html(covid_data):
template_str = open(page_path, 'r', encoding='utf-8').read()
template = Template(template_str)
page = template.substitute(
covid_data=json.dumps(covid_data),
data_info=get_info(covid_data)
)
return page
А по пути page_path
лежит примерно такое:
$data_info
Профит! Данные инжектятся в страничку! Можно отправлять в браузер!
Как зайти на страничку?
Вот. Простейший веб сервер на чистейшем Python:
from http.server import BaseHTTPRequestHandler
from lib.data_loader import Covid19DataLoader
from lib.page_maker import get_html
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == '/':
self.send_response(200)
self.send_header('Content-Type', 'text/html')
self.end_headers()
try:
response = get_html(Covid19DataLoader().load())
self.wfile.write(response.encode('utf-8'))
except Exception as e:
self.send_error(500)
print(f'{type(e).__name__}: {e}')
else:
self.send_error(404)
Не рекомендуется для продакшна, но для продакшна у нас будет GitLab Pages, не переключайте канал вкладку!
Внимательный читатель кода мог заметить, что данные качаются при каждом запросе, что ну совсем не подходит для продакшна, да и для медленного дачного Интернета не очень. Решение: кэширование в файл. Для инвалидации кэша просто удаляем файл (например, из cron). Проще не придумать, и ведь работает!
Данные для правильных графиков
Как завещал Aatish Bhatia, по оси Y должно быть количество новых случаев заражения за последнюю неделю, а по оси X — общее количество заражённых на момент времени.
Только мы возьмём не за неделю, а за 3 дня, чтобы график был более чувствительным и быстрее отражал ситуацию. Вопрос «Какое окно брать?» мною детально не исследовался, если Вам кажется, что лучше взять подлиннее, и колебания графика туда-сюда (которые наблюдаются в данных за последнюю неделю) только мешают, пишите в коменты!
from datetime import timedelta
from functools import reduce
y_axis_window = timedelta(days=3).total_seconds()
def get_cases_in_window(data, current_time):
window_open_time = current_time - y_axis_window
cases_in_window = list(filter(lambda s: window_open_time <= s['ts'] < current_time, data))
return cases_in_window
def differentiate(data):
result = [data[0]]
for prev_i, cur_sample in enumerate(data[1:]):
result.append({
'ts': cur_sample['ts'],
'value': cur_sample['value'] - data[prev_i]['value']
})
return result
def get_trend(histogram):
trend = []
new_cases = differentiate(histogram)
for sample in histogram:
current_time = sample['ts']
total_cases = sample['value']
new_cases_in_window = get_cases_in_window(new_cases, current_time)
total_new_cases_in_window = reduce(lambda a, c: a + c['value'], new_cases_in_window, 0)
trend.append({'x': total_cases,'y': total_new_cases_in_window})
return trend
def get_trends(data_items):
return { area['name']: get_trend(area['histogram']) for area in data_items }
Фронтэнд
Тут нет ничего, чем я хотел бы поделиться (разве что восторгом от CSS Grid, который впервые попробовал), так как разбираюсь во фронтэнде хуже всего. Прошу поправить меня мёржреквестом, если я где-то накосячил. Вот код.
Как опубликовать сервис?
Я очень люблю Docker, и первый вариант, который мне пришёл на ум — склонировать репозиторий на свой VPS, позвать docker-compose up --build -d
, настроить cron на удаление кэш-файла (хотя постойте, какой крон внутри контейнера? ладно, потом разберёмся…), и, вопреки предупреждениям о том, что http.server не для продакшна, открыть порт во внешку. А потом натравить GitLab CI на обновление сервиса по ssh (я сто раз уже так делал).
Но тут я заметил, что страничка, отправляемая в браузер, весьма статична.
Это позволяет её просто выгрузить в файл и захостить бесплатно в GitLab Pages:
import os
from lib.data_loader import Covid19DataLoader
from lib.data_processor import get_trends
from lib.page_maker import get_html
page_dir = 'public'
page_name = 'index.html'
print('Updating Covid-19 data from Yandex...')
raw_data = Covid19DataLoader().load()
print('Calculating trends...')
trends = get_trends(raw_data['items'])
page = get_html({'raw_data': raw_data, 'trends': trends})
if not os.path.isdir(page_dir):
os.mkdir(page_dir)
page_path = os.path.join(page_dir, page_name)
open(page_path, 'w', encoding='utf-8').write(page)
print(f'Page saved as "{page_path}"')
Для того, чтобы опубликовать страничку по симпатичному адресу https://himura.gitlab.io/covid19, достаточно написать следующий .gitlab-ci.yml
:
image: python
pages:
stage: deploy
only: [ master ]
script:
- python ./get_static_html.py
artifacts:
paths: [ public ]
А обновлять через Pipeline Schedules:
Результат работы выглядит как-то так:
А результат деплоймента — как на КДПВ:
Всё?
Кодснипеты в статье немного упрощены, а весь реальный код в репозитории на GitLab: https://gitlab.com/himura/covid19
Я почти уверен, что много чего упустил, так как на весь код и статью в сумме было потрачено примерно 2 рабочих дня. Пожалуйста, давайте вместе что-нибудь улучшим в этом MVP, если Вам кажется, что что-то не так. Пишите комментарии, пишите баги, а лучше всего — пишите мёржреквесты. В числе known issues:
- Добавить даты в тултип точкам
- Добавить мобильную версию (с css grid должно быть радикально просто)
- Убедиться, что математика работает как надо
Надеюсь, статья была интересна и/или полезна, а также очень надеюсь, что вскоре мы все будем наблюдать, как линии графиков устремятся вниз.
Будьте здоровы и не вносите в свой код зависимостей, без которых можно обойтись!