FInished work
(Too lazy to split by commits)
This commit is contained in:
parent
600daa5498
commit
c40a1b4f92
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
.~lock*
|
||||||
|
**.mypy_cache/
|
||||||
|
**.venv/
|
||||||
|
**__pycache__/
|
||||||
|
*.docx
|
||||||
|
data*.csv
|
5
.env.example
Normal file
5
.env.example
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
REFETCH_PERIOD_H=6
|
||||||
|
POSTGRES_USER=rosseti
|
||||||
|
POSTGRES_PASSWORD=rosseti
|
||||||
|
POSTGRES_DB=rosseti
|
||||||
|
POSTGRES_HOST=db
|
15
.vscode/launch.json
vendored
Normal file
15
.vscode/launch.json
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Python: Module",
|
||||||
|
"type": "python",
|
||||||
|
"request": "launch",
|
||||||
|
"module": "parser",
|
||||||
|
"justMyCode": true,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
7
Dockerfile
Normal file
7
Dockerfile
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
FROM python:3-slim
|
||||||
|
WORKDIR /srv
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
||||||
|
COPY ./parser_api ./parser_api
|
||||||
|
COPY ./rosseti_parser ./rosseti_parser
|
||||||
|
CMD python -m uvicorn parser_api.main:app
|
87
README.md
87
README.md
@ -1,82 +1,29 @@
|
|||||||
**СПБ ГУП «ИАЦ»**
|
# СПБ ГУП «ИАЦ»
|
||||||
|
|
||||||
**Тестовое задание для Python-разработчика (дата аналитика)**
|
## Тестовое задание для Python-разработчика (дата аналитика)
|
||||||
|
|
||||||
О компании:
|
Задание: [Task.md](https://git.dm1sh.ru/dm1sh/iac_test/src/branch/main/Task.md)
|
||||||
|
|
||||||
Государственное унитарное предприятие, работающее в области
|
- Скрипт для парсинга и работа с данными: [rosseti_parser](https://git.dm1sh.ru/dm1sh/iac_test/src/branch/main/rosseti_parser)
|
||||||
информатизации и информационного обеспечения органов государственной
|
|
||||||
власти Санкт-Петербурга и других организаций, а также предоставления
|
|
||||||
услуг в сфере создания и использования современных информационных и
|
|
||||||
телекоммуникационных систем, средств и технологий
|
|
||||||
|
|
||||||
Более подробно о нас: <https://iac.spb.ru/>
|
Модуль можно запустить командой `python -m rosseti_parser`, или импортировать из него необходимые методы и использовать где-то ещё
|
||||||
|
|
||||||
Мы на Хабре: <https://career.habr.com/companies/iac-spb>
|
- Анализ данных: [main.ipynb](https://git.dm1sh.ru/dm1sh/iac_test/src/branch/main/main.ipynb)
|
||||||
|
|
||||||
**Текст задания:**
|
Необходимо скачать (платформа пока не поддерживает отображение ноутбука). Для запуска, установить библиотеки из `requirements.dev.txt`
|
||||||
|
|
||||||
**- Парсинг**
|
- Визуализация: [Yandex DataLens](https://datalens.yandex/kq0ymfuy5w7e9)
|
||||||
|
|
||||||
Считывание таблицы с сайта <https://rosseti-lenenergo.ru/planned_work/>
|
Простенький дашборд с картой точек, хитмапом и несколькими графиками
|
||||||
|
|
||||||
{width="6.818295056867892in"
|
- Создание базы данных, API и docker: [parser_api](https://git.dm1sh.ru/dm1sh/iac_test/src/branch/main/parser_api)
|
||||||
height="2.37117125984252in"}
|
|
||||||
|
|
||||||
Фильтр по времени - период текущей недели (текущий день и неделя вперед)
|
FastAPI приложение, запускающее в дополнительном потоке периодическое обновление данных в базе.
|
||||||
|
|
||||||
- Успешное чтение необходимых полей на сайте и их сохранение в
|
Доступные методы:
|
||||||
pandas.DataFrame
|
|
||||||
|
|
||||||
- Переключение между страницами и совершение полной выгрузки
|
- GET /api/list - Поиск по каждому полю в отдельности
|
||||||
{width="2.468441601049869in"
|
- GET /api/search - Поиск по всем полям сразу
|
||||||
height="0.49993766404199474in"}
|
- GET /api/check - Проверка, является ли отключение в вашем доме сейчас официальным и если да, то когда сеть снова включат.
|
||||||
|
- PUT /api/create - Отладочное поле для добавления записей в БД
|
||||||
- Настройка автоматического запуска скрипта по расписанию
|
- GET / - Healthcheck
|
||||||
|
|
||||||
**- Работа с данными**
|
|
||||||
|
|
||||||
- Парсинг столбца Улица (разбиение строки на отдельные адреса)
|
|
||||||
|
|
||||||
- Геокод адресов через
|
|
||||||
<https://petersburg.ru/mainPortal/api_services/view/2223> и
|
|
||||||
сохранение building_id найденных зданий
|
|
||||||
|
|
||||||
- Запись результата в csv файл
|
|
||||||
|
|
||||||
**- Анализ данных и Визуализация**
|
|
||||||
|
|
||||||
- Выполнить в свободной форме на основе данных, полученных ранее
|
|
||||||
|
|
||||||
В дополнение к скрипту можно сделать дашборд на [Yandex DataLens
|
|
||||||
/Grafana](https://www.google.com/url?sa=t&rct=j&q=&esrc=s&source=web&cd=&ved=2ahUKEwiCrdStlbKBAxViSPEDHcw0B4kQFnoECB0QAQ&url=https%3A%2F%2Fdatalens.yandex.ru%2F&usg=AOvVaw3gsVaz_KTvGMRtFZrsXAGk&opi=89978449)
|
|
||||||
|
|
||||||
**\*Создание базы данных**
|
|
||||||
|
|
||||||
- Вместо сохранения результата в csv, развернуть базу данных и
|
|
||||||
сохранять результаты в неё (PostgreSQL, Clickhouse, MongoDB)
|
|
||||||
|
|
||||||
- (Можно сделать скелет с подключением и записью в бд на локалхосте)
|
|
||||||
|
|
||||||
**\*API**
|
|
||||||
|
|
||||||
- Написать API к базе данных или csv-файлу на FastAPI
|
|
||||||
|
|
||||||
**\*\*Docker**
|
|
||||||
|
|
||||||
- Оборачивание всей сделанной работы в docker-compose
|
|
||||||
|
|
||||||
- При первоначальной настройке и запуске компоуза парсер начнет
|
|
||||||
работать и собирать данные в БД / csv. Доступ к данным
|
|
||||||
осуществляется по API.
|
|
||||||
|
|
||||||
Результаты проделанной работы залить на Github и прислать на
|
|
||||||
[tg:Faneagain](https://t.me/faneagain)
|
|
||||||
|
|
||||||
//При проблемах с парсингом для выполнения остальных задач можно
|
|
||||||
попросить готовый набор данных
|
|
||||||
|
|
||||||
Задания помеченные «**\***, **\*\***» будут оцениваться как
|
|
||||||
дополнительные.
|
|
||||||
|
|
||||||
Удачи!
|
|
||||||
|
82
Task.md
Normal file
82
Task.md
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
**СПБ ГУП «ИАЦ»**
|
||||||
|
|
||||||
|
**Тестовое задание для Python-разработчика (дата аналитика)**
|
||||||
|
|
||||||
|
О компании:
|
||||||
|
|
||||||
|
Государственное унитарное предприятие, работающее в области
|
||||||
|
информатизации и информационного обеспечения органов государственной
|
||||||
|
власти Санкт-Петербурга и других организаций, а также предоставления
|
||||||
|
услуг в сфере создания и использования современных информационных и
|
||||||
|
телекоммуникационных систем, средств и технологий
|
||||||
|
|
||||||
|
Более подробно о нас: <https://iac.spb.ru/>
|
||||||
|
|
||||||
|
Мы на Хабре: <https://career.habr.com/companies/iac-spb>
|
||||||
|
|
||||||
|
**Текст задания:**
|
||||||
|
|
||||||
|
**- Парсинг**
|
||||||
|
|
||||||
|
Считывание таблицы с сайта <https://rosseti-lenenergo.ru/planned_work/>
|
||||||
|
|
||||||
|
{width="6.818295056867892in"
|
||||||
|
height="2.37117125984252in"}
|
||||||
|
|
||||||
|
Фильтр по времени - период текущей недели (текущий день и неделя вперед)
|
||||||
|
|
||||||
|
- Успешное чтение необходимых полей на сайте и их сохранение в
|
||||||
|
pandas.DataFrame
|
||||||
|
|
||||||
|
- Переключение между страницами и совершение полной выгрузки
|
||||||
|
{width="2.468441601049869in"
|
||||||
|
height="0.49993766404199474in"}
|
||||||
|
|
||||||
|
- Настройка автоматического запуска скрипта по расписанию
|
||||||
|
|
||||||
|
**- Работа с данными**
|
||||||
|
|
||||||
|
- Парсинг столбца Улица (разбиение строки на отдельные адреса)
|
||||||
|
|
||||||
|
- Геокод адресов через
|
||||||
|
<https://petersburg.ru/mainPortal/api_services/view/2223> и
|
||||||
|
сохранение building_id найденных зданий
|
||||||
|
|
||||||
|
- Запись результата в csv файл
|
||||||
|
|
||||||
|
**- Анализ данных и Визуализация**
|
||||||
|
|
||||||
|
- Выполнить в свободной форме на основе данных, полученных ранее
|
||||||
|
|
||||||
|
В дополнение к скрипту можно сделать дашборд на [Yandex DataLens
|
||||||
|
/Grafana](https://www.google.com/url?sa=t&rct=j&q=&esrc=s&source=web&cd=&ved=2ahUKEwiCrdStlbKBAxViSPEDHcw0B4kQFnoECB0QAQ&url=https%3A%2F%2Fdatalens.yandex.ru%2F&usg=AOvVaw3gsVaz_KTvGMRtFZrsXAGk&opi=89978449)
|
||||||
|
|
||||||
|
**\*Создание базы данных**
|
||||||
|
|
||||||
|
- Вместо сохранения результата в csv, развернуть базу данных и
|
||||||
|
сохранять результаты в неё (PostgreSQL, Clickhouse, MongoDB)
|
||||||
|
|
||||||
|
- (Можно сделать скелет с подключением и записью в бд на локалхосте)
|
||||||
|
|
||||||
|
**\*API**
|
||||||
|
|
||||||
|
- Написать API к базе данных или csv-файлу на FastAPI
|
||||||
|
|
||||||
|
**\*\*Docker**
|
||||||
|
|
||||||
|
- Оборачивание всей сделанной работы в docker-compose
|
||||||
|
|
||||||
|
- При первоначальной настройке и запуске компоуза парсер начнет
|
||||||
|
работать и собирать данные в БД / csv. Доступ к данным
|
||||||
|
осуществляется по API.
|
||||||
|
|
||||||
|
Результаты проделанной работы залить на Github и прислать на
|
||||||
|
[tg:Faneagain](https://t.me/faneagain)
|
||||||
|
|
||||||
|
//При проблемах с парсингом для выполнения остальных задач можно
|
||||||
|
попросить готовый набор данных
|
||||||
|
|
||||||
|
Задания помеченные «**\***, **\*\***» будут оцениваться как
|
||||||
|
дополнительные.
|
||||||
|
|
||||||
|
Удачи!
|
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
version: "3"
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- db_storage:/var/lib/postgresql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
hostname: "db"
|
||||||
|
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
links:
|
||||||
|
- db
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_storage:
|
1684
main.ipynb
Normal file
1684
main.ipynb
Normal file
File diff suppressed because one or more lines are too long
@ -1,4 +0,0 @@
|
|||||||
from .rosseti import RossetiParser
|
|
||||||
from .address import split_addresses
|
|
||||||
from .building_id import fetch_builing_ids
|
|
||||||
from .preprocess import preprocess_df
|
|
@ -1,25 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
from typing import Optional, Tuple, Any
|
|
||||||
|
|
||||||
import requests
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
|
|
||||||
def get_building_id(row: pd.Series[Any]) -> Optional[Tuple[int, float, float]]:
|
|
||||||
r = requests.get('https://geocode.gate.petersburg.ru/parse/eas', params={
|
|
||||||
'street': row['Улица']
|
|
||||||
})
|
|
||||||
|
|
||||||
res = r.json()
|
|
||||||
|
|
||||||
if 'error' not in res:
|
|
||||||
return (res['Building_ID'], res['Longitude'], res['Latitude'])
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_builing_ids(df: pd.DataFrame) -> pd.DataFrame:
|
|
||||||
df[['Building_ID', 'lng', 'lat']] = df.apply(
|
|
||||||
get_building_id, axis=1, result_type='expand')
|
|
||||||
|
|
||||||
return df
|
|
@ -1,19 +0,0 @@
|
|||||||
import pandas as pd
|
|
||||||
|
|
||||||
|
|
||||||
def preprocess_df(df: pd.DataFrame) -> pd.DataFrame:
|
|
||||||
df['start'] = df['Плановая дата начала отключения электроснабжения'] + \
|
|
||||||
' ' + df['Плановое время начала отключения электроснабжения']
|
|
||||||
|
|
||||||
df['finish'] = df['Плановая дата восстановления отключения электроснабжения'] + \
|
|
||||||
' ' + df['Плановое время восстановления отключения электроснабжения']
|
|
||||||
|
|
||||||
df = df.drop(columns=[
|
|
||||||
'Улица',
|
|
||||||
'Плановая дата начала отключения электроснабжения',
|
|
||||||
'Плановая дата восстановления отключения электроснабжения',
|
|
||||||
'Плановое время начала отключения электроснабжения',
|
|
||||||
'Плановое время восстановления отключения электроснабжения'
|
|
||||||
])
|
|
||||||
|
|
||||||
return df
|
|
0
parser_api/__init__.py
Normal file
0
parser_api/__init__.py
Normal file
9
parser_api/config.py
Normal file
9
parser_api/config.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
REFETCH_PERIOD_H = int(os.environ.get('REFETCH_PERIOD_H', '4'))
|
||||||
|
|
||||||
|
POSTGRES_USER = os.environ.get('POSTGRES_USER', 'rosseti')
|
||||||
|
POSTGRES_PASSWORD = os.environ.get('POSTGRES_PASSWORD', 'rosseti')
|
||||||
|
POSTGRES_DB = os.environ.get('POSTGRES_DB', 'rosseti')
|
||||||
|
POSTGRES_HOST = os.environ.get('POSTGRES_HOST', 'localhost')
|
||||||
|
POSTGRES_PORT = int(os.environ.get('POSTGRES_PORT', '5432'))
|
111
parser_api/controller.py
Normal file
111
parser_api/controller.py
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
from functools import reduce
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import func, True_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.sql import operators
|
||||||
|
from sqlalchemy.sql.expression import BinaryExpression
|
||||||
|
|
||||||
|
from . import models, schemas
|
||||||
|
|
||||||
|
|
||||||
|
def create_record(db: Session, record: schemas.Record):
|
||||||
|
db_record = models.Record(
|
||||||
|
region=record.region,
|
||||||
|
area=record.area,
|
||||||
|
town=record.town,
|
||||||
|
street=record.street,
|
||||||
|
start=record.start,
|
||||||
|
finish=record.finish,
|
||||||
|
branch=record.branch,
|
||||||
|
res=record.res,
|
||||||
|
comment=record.comment,
|
||||||
|
building_id=record.building_id,
|
||||||
|
lat=record.lat,
|
||||||
|
lng=record.lng,
|
||||||
|
)
|
||||||
|
db.add(db_record)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_record)
|
||||||
|
|
||||||
|
return db_record
|
||||||
|
|
||||||
|
|
||||||
|
def contains_lower(name, val):
|
||||||
|
if type(val) == str:
|
||||||
|
return getattr(models.Record, name).icontains(val)
|
||||||
|
else:
|
||||||
|
return getattr(models.Record, name) == val
|
||||||
|
|
||||||
|
|
||||||
|
def and_if_can(a: BinaryExpression, b: Optional[BinaryExpression]):
|
||||||
|
if b is not None:
|
||||||
|
return a & b
|
||||||
|
else:
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
|
def search_each(db: Session, filters: schemas.RecordRequest) -> List[schemas.Record]:
|
||||||
|
query = None
|
||||||
|
|
||||||
|
if filters.start:
|
||||||
|
query = (models.Record.start <= filters.start)
|
||||||
|
if filters.finish:
|
||||||
|
query = and_if_can(models.Record.finish >= filters.finish, query)
|
||||||
|
|
||||||
|
filters = list(
|
||||||
|
filter(lambda x: x[1] is not None and x[0] not in ('start, finish'), filters))
|
||||||
|
|
||||||
|
query = reduce(lambda acc, ftr: and_if_can(
|
||||||
|
contains_lower(*ftr), acc), filters, query)
|
||||||
|
|
||||||
|
if query is None:
|
||||||
|
res = db.query(models.Record).all()
|
||||||
|
|
||||||
|
res = db.query(models.Record).filter(query).all()
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def search_all(db: Session, prompt: str) -> List[schemas.Record]:
|
||||||
|
prompt = prompt.strip()
|
||||||
|
|
||||||
|
query = reduce(lambda acc, name: acc | contains_lower(name, prompt), (
|
||||||
|
'region',
|
||||||
|
'area',
|
||||||
|
'town',
|
||||||
|
'street',
|
||||||
|
'branch',
|
||||||
|
'res'
|
||||||
|
), contains_lower('comment', prompt))
|
||||||
|
|
||||||
|
res = db.query(models.Record).filter(query).all()
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def check_outage(db: Session, building_id: int) -> schemas.CheckResponse:
|
||||||
|
building_query = db.query(models.Record).filter(
|
||||||
|
(models.Record.building_id == building_id))
|
||||||
|
|
||||||
|
if building_query.count() == 0:
|
||||||
|
raise HTTPException(404, 'No such building')
|
||||||
|
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
|
||||||
|
res = building_query.filter(
|
||||||
|
(models.Record.start <= now) &
|
||||||
|
(now <= models.Record.finish)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if res is None:
|
||||||
|
return {
|
||||||
|
'is_outage': False
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_outage': True,
|
||||||
|
'when_finish': res.finish
|
||||||
|
}
|
31
parser_api/database.py
Normal file
31
parser_api/database.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
from typing import Generator
|
||||||
|
import os
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, URL
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker, Session
|
||||||
|
|
||||||
|
from .config import POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_HOST, POSTGRES_PORT, POSTGRES_DB
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
URL.create(
|
||||||
|
"postgresql+psycopg",
|
||||||
|
username=POSTGRES_USER,
|
||||||
|
password=POSTGRES_PASSWORD,
|
||||||
|
host=POSTGRES_HOST,
|
||||||
|
port=POSTGRES_PORT,
|
||||||
|
database=POSTGRES_DB,
|
||||||
|
),
|
||||||
|
client_encoding='utf8',
|
||||||
|
)
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
# Dependency
|
||||||
|
def get_db() -> Generator[Session, None, None]:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
31
parser_api/job.py
Normal file
31
parser_api/job.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
from rosseti_parser import pipeline, preprocess_read_df
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .database import get_db
|
||||||
|
from . import models
|
||||||
|
|
||||||
|
from io import StringIO
|
||||||
|
|
||||||
|
|
||||||
|
def job():
|
||||||
|
fetch_start = datetime.now()
|
||||||
|
print("Starting refetch job: " + fetch_start.isoformat())
|
||||||
|
|
||||||
|
db = next(get_db())
|
||||||
|
|
||||||
|
parser = pipeline()
|
||||||
|
|
||||||
|
db.query(models.Record).delete()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
print("Rewriting db: " + datetime.now().isoformat())
|
||||||
|
|
||||||
|
for i, row in parser.df.iterrows():
|
||||||
|
row = row.where((pd.notnull(row)), None)
|
||||||
|
db.add(models.Record(**row.to_dict()))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
print(f"Fetched in {datetime.now() - fetch_start}\n{parser}")
|
37
parser_api/main.py
Normal file
37
parser_api/main.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
import schedule
|
||||||
|
|
||||||
|
from . import models, router
|
||||||
|
from .database import engine
|
||||||
|
from .scheduler import run_continuously, run_threaded
|
||||||
|
from .job import job
|
||||||
|
from .config import REFETCH_PERIOD_H
|
||||||
|
|
||||||
|
models.Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
start_stamp = datetime.datetime.now()
|
||||||
|
|
||||||
|
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
schedule.every(REFETCH_PERIOD_H).hours.do(job)
|
||||||
|
stop_run_continuously = run_continuously()
|
||||||
|
|
||||||
|
run_threaded(job)
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
stop_run_continuously()
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
|
app.include_router(router.router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get('/')
|
||||||
|
def root():
|
||||||
|
return {
|
||||||
|
"up_since": start_stamp
|
||||||
|
}
|
23
parser_api/models.py
Normal file
23
parser_api/models.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
from sqlalchemy import Boolean, Column, Integer, String, DateTime, Float
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from .database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Record(Base):
|
||||||
|
__tablename__ = 'records'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
index = Column(Integer)
|
||||||
|
region = Column(String, nullable=True)
|
||||||
|
area = Column(String, nullable=True)
|
||||||
|
town = Column(String, nullable=True)
|
||||||
|
street = Column(String, nullable=True)
|
||||||
|
start = Column(DateTime)
|
||||||
|
finish = Column(DateTime)
|
||||||
|
branch = Column(String, nullable=True)
|
||||||
|
res = Column(String, nullable=True)
|
||||||
|
comment = Column(String, nullable=True)
|
||||||
|
building_id = Column(Integer, nullable=True)
|
||||||
|
lat = Column(Float, nullable=True)
|
||||||
|
lng = Column(Float, nullable=True)
|
33
parser_api/router.py
Normal file
33
parser_api/router.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
from fastapi import HTTPException, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List, Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from . import models, schemas, controller
|
||||||
|
from .database import SessionLocal, get_db
|
||||||
|
|
||||||
|
router = APIRouter(prefix='/api')
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/list', response_model=List[schemas.Record])
|
||||||
|
def list_rows(
|
||||||
|
filters: Annotated[schemas.RecordRequest, Depends()],
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
return controller.search_each(db, filters)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/search', response_model=List[schemas.Record])
|
||||||
|
def search_rows(query: str, db: Session = Depends(get_db)):
|
||||||
|
return controller.search_all(db, query)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/check', response_model=schemas.CheckResponse)
|
||||||
|
def check(building_id: int, db: Session = Depends(get_db)):
|
||||||
|
return controller.check_outage(db, building_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put('/create', response_model=schemas.Record)
|
||||||
|
def create_record(record: schemas.RecordCreate, db: Session = Depends(get_db)):
|
||||||
|
return controller.create_record(db, record)
|
25
parser_api/scheduler.py
Normal file
25
parser_api/scheduler.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import schedule
|
||||||
|
|
||||||
|
|
||||||
|
def run_continuously(interval=1):
|
||||||
|
cease_continuous_run = threading.Event()
|
||||||
|
|
||||||
|
class ScheduleThread(threading.Thread):
|
||||||
|
@classmethod
|
||||||
|
def run(cls):
|
||||||
|
while not cease_continuous_run.is_set():
|
||||||
|
schedule.run_pending()
|
||||||
|
time.sleep(interval)
|
||||||
|
|
||||||
|
continuous_thread = ScheduleThread()
|
||||||
|
continuous_thread.start()
|
||||||
|
|
||||||
|
return cease_continuous_run.set
|
||||||
|
|
||||||
|
|
||||||
|
def run_threaded(job):
|
||||||
|
job_thread = threading.Thread(target=job)
|
||||||
|
job_thread.start()
|
39
parser_api/schemas.py
Normal file
39
parser_api/schemas.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class BaseRecord(BaseModel):
|
||||||
|
index: Optional[int] = None
|
||||||
|
region: Optional[str] = None
|
||||||
|
area: Optional[str] = None
|
||||||
|
town: Optional[str] = None
|
||||||
|
street: Optional[str] = None
|
||||||
|
branch: Optional[str] = None
|
||||||
|
res: Optional[str] = None
|
||||||
|
comment: Optional[str] = None
|
||||||
|
building_id: Optional[int] = None
|
||||||
|
lat: Optional[float] = None
|
||||||
|
lng: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Record(BaseRecord):
|
||||||
|
id: int
|
||||||
|
start: datetime.datetime
|
||||||
|
finish: datetime.datetime
|
||||||
|
|
||||||
|
|
||||||
|
class RecordRequest(BaseRecord):
|
||||||
|
start: Optional[datetime.datetime] = None
|
||||||
|
finish: Optional[datetime.datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RecordCreate(BaseRecord):
|
||||||
|
start: datetime.datetime
|
||||||
|
finish: datetime.datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CheckResponse(BaseModel):
|
||||||
|
is_outage: bool
|
||||||
|
when_finish: Optional[datetime.datetime] = None
|
72
requirements.dev.txt
Normal file
72
requirements.dev.txt
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
annotated-types==0.5.0
|
||||||
|
anyio==3.7.1
|
||||||
|
asttokens==2.4.0
|
||||||
|
autopep8==2.0.4
|
||||||
|
backcall==0.2.0
|
||||||
|
beautifulsoup4==4.12.2
|
||||||
|
certifi==2023.7.22
|
||||||
|
charset-normalizer==3.2.0
|
||||||
|
comm==0.1.4
|
||||||
|
contourpy==1.1.1
|
||||||
|
cycler==0.11.0
|
||||||
|
debugpy==1.8.0
|
||||||
|
decorator==5.1.1
|
||||||
|
executing==1.2.0
|
||||||
|
fastapi==0.103.1
|
||||||
|
fonttools==4.42.1
|
||||||
|
greenlet==2.0.2
|
||||||
|
idna==3.4
|
||||||
|
ipykernel==6.25.2
|
||||||
|
ipython==8.15.0
|
||||||
|
jedi==0.19.0
|
||||||
|
jupyter_client==8.3.1
|
||||||
|
jupyter_core==5.3.1
|
||||||
|
kiwisolver==1.4.5
|
||||||
|
lxml==4.9.3
|
||||||
|
matplotlib==3.8.0
|
||||||
|
matplotlib-inline==0.1.6
|
||||||
|
mypy==1.5.1
|
||||||
|
mypy-extensions==1.0.0
|
||||||
|
nest-asyncio==1.5.8
|
||||||
|
numpy==1.26.0
|
||||||
|
packaging==23.1
|
||||||
|
pandas==2.1.0
|
||||||
|
pandas-stubs==2.0.3.230814
|
||||||
|
parso==0.8.3
|
||||||
|
pexpect==4.8.0
|
||||||
|
pickleshare==0.7.5
|
||||||
|
Pillow==10.0.1
|
||||||
|
platformdirs==3.10.0
|
||||||
|
prompt-toolkit==3.0.39
|
||||||
|
psutil==5.9.5
|
||||||
|
ptyprocess==0.7.0
|
||||||
|
pure-eval==0.2.2
|
||||||
|
pycodestyle==2.11.0
|
||||||
|
pydantic==2.3.0
|
||||||
|
pydantic_core==2.6.3
|
||||||
|
Pygments==2.16.1
|
||||||
|
pyparsing==3.1.1
|
||||||
|
python-dateutil==2.8.2
|
||||||
|
pytz==2023.3.post1
|
||||||
|
pyzmq==25.1.1
|
||||||
|
requests==2.31.0
|
||||||
|
schedule==1.2.0
|
||||||
|
scipy==1.11.2
|
||||||
|
seaborn==0.12.2
|
||||||
|
six==1.16.0
|
||||||
|
sniffio==1.3.0
|
||||||
|
soupsieve==2.5
|
||||||
|
SQLAlchemy==2.0.21
|
||||||
|
stack-data==0.6.2
|
||||||
|
starlette==0.27.0
|
||||||
|
tornado==6.3.3
|
||||||
|
traitlets==5.10.0
|
||||||
|
types-beautifulsoup4==4.12.0.6
|
||||||
|
types-html5lib==1.1.11.15
|
||||||
|
types-pytz==2023.3.1.0
|
||||||
|
types-requests==2.31.0.2
|
||||||
|
types-urllib3==1.26.25.14
|
||||||
|
typing_extensions==4.8.0
|
||||||
|
tzdata==2023.3
|
||||||
|
urllib3==2.0.4
|
||||||
|
wcwidth==0.2.6
|
32
requirements.txt
Normal file
32
requirements.txt
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
annotated-types==0.5.0
|
||||||
|
anyio==3.7.1
|
||||||
|
beautifulsoup4==4.12.2
|
||||||
|
bs4==0.0.1
|
||||||
|
certifi==2023.7.22
|
||||||
|
charset-normalizer==3.2.0
|
||||||
|
click==8.1.7
|
||||||
|
fastapi==0.103.1
|
||||||
|
greenlet==2.0.2
|
||||||
|
h11==0.14.0
|
||||||
|
idna==3.4
|
||||||
|
lxml==4.9.3
|
||||||
|
numpy==1.26.0
|
||||||
|
pandas==2.1.1
|
||||||
|
psycopg==3.1.10
|
||||||
|
psycopg-binary==3.1.10
|
||||||
|
psycopg-pool==3.1.7
|
||||||
|
pydantic==2.3.0
|
||||||
|
pydantic_core==2.6.3
|
||||||
|
python-dateutil==2.8.2
|
||||||
|
pytz==2023.3.post1
|
||||||
|
requests==2.31.0
|
||||||
|
schedule==1.2.0
|
||||||
|
six==1.16.0
|
||||||
|
sniffio==1.3.0
|
||||||
|
soupsieve==2.5
|
||||||
|
SQLAlchemy==2.0.21
|
||||||
|
starlette==0.27.0
|
||||||
|
typing_extensions==4.8.0
|
||||||
|
tzdata==2023.3
|
||||||
|
urllib3==2.0.5
|
||||||
|
uvicorn==0.23.2
|
5
rosseti_parser/__init__.py
Normal file
5
rosseti_parser/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from .rosseti import RossetiParser
|
||||||
|
from .address import split_addresses
|
||||||
|
from .building_id import fetch_builing_ids
|
||||||
|
from .preprocess import preprocess_df, COL_NS, ICOL_NS, preprocess_read_df, group_by_index
|
||||||
|
from .util import pipeline
|
@ -2,20 +2,11 @@ import sys
|
|||||||
import schedule
|
import schedule
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from . import RossetiParser, split_addresses, fetch_builing_ids, preprocess_df
|
from . import pipeline
|
||||||
|
|
||||||
|
|
||||||
def job() -> None:
|
def job():
|
||||||
parser = RossetiParser()
|
parser = pipeline()
|
||||||
|
|
||||||
print(parser)
|
|
||||||
|
|
||||||
parser.df = split_addresses(parser.df)
|
|
||||||
|
|
||||||
parser.df = fetch_builing_ids(parser.df)
|
|
||||||
|
|
||||||
parser.df = preprocess_df(parser.df)
|
|
||||||
|
|
||||||
parser.save_df(f'./data_{parser.today.strftime("%d-%m-%y_%H:%M")}.csv')
|
parser.save_df(f'./data_{parser.today.strftime("%d-%m-%y_%H:%M")}.csv')
|
||||||
|
|
||||||
|
|
@ -6,9 +6,9 @@ import re
|
|||||||
|
|
||||||
T = TypeVar('T')
|
T = TypeVar('T')
|
||||||
|
|
||||||
street_prefixes = ('ул.', 'бул.', 'пр.', 'ул', 'бул',
|
STREET_PREFIXES = ('ул.', 'бул.', 'пр.', 'ул', 'бул',
|
||||||
'пр', 'ш.', 'ш', 'пер.', 'пер')
|
'пр', 'ш.', 'ш', 'пер.', 'пер')
|
||||||
houses_prefixes = ('д.', 'д')
|
HOUSES_PREFIXES = ('д.', 'д')
|
||||||
|
|
||||||
|
|
||||||
def unfold_house_ranges(token: str) -> str:
|
def unfold_house_ranges(token: str) -> str:
|
||||||
@ -56,8 +56,8 @@ def split_address(address: str) -> List[str]:
|
|||||||
accumulator = ''
|
accumulator = ''
|
||||||
|
|
||||||
for i in range(len(tokens)):
|
for i in range(len(tokens)):
|
||||||
if (any_of_in(street_prefixes, tokens[i].lower()) and
|
if (any_of_in(STREET_PREFIXES, tokens[i].lower()) and
|
||||||
any_of_in(street_prefixes, accumulator.lower())):
|
any_of_in(STREET_PREFIXES, accumulator.lower())):
|
||||||
res += unfold_houses_list(accumulator)
|
res += unfold_houses_list(accumulator)
|
||||||
accumulator = ''
|
accumulator = ''
|
||||||
|
|
||||||
@ -71,17 +71,18 @@ def split_address(address: str) -> List[str]:
|
|||||||
|
|
||||||
|
|
||||||
def process_row(row: pd.Series[str]) -> pd.Series[str]:
|
def process_row(row: pd.Series[str]) -> pd.Series[str]:
|
||||||
if pd.isnull(row['Улица']):
|
|
||||||
return row
|
|
||||||
|
|
||||||
addresses = split_address(row['Улица'])
|
|
||||||
|
|
||||||
row = row.copy()
|
row = row.copy()
|
||||||
|
|
||||||
row['Улица'] = addresses
|
if pd.isnull(row['Улица']):
|
||||||
|
row['Улица'] = [None]
|
||||||
|
else:
|
||||||
|
addresses = split_address(row['Улица'])
|
||||||
|
row['Улица'] = addresses
|
||||||
|
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
def split_addresses(df: pd.DataFrame) -> pd.DataFrame:
|
def split_addresses(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
return df.apply(process_row, axis=1).explode('Улица', ignore_index=True)
|
merged_df = df.apply(process_row, axis=1).reset_index()
|
||||||
|
|
||||||
|
return merged_df.explode('Улица', ignore_index=True)
|
31
rosseti_parser/building_id.py
Normal file
31
rosseti_parser/building_id.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from typing import Optional, Tuple, Any, List
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
GeoTupleType = Tuple[Optional[int], Optional[float], Optional[float]]
|
||||||
|
|
||||||
|
|
||||||
|
def get_building_id(street: str) -> GeoTupleType:
|
||||||
|
if pd.isnull(street):
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
r = requests.get('https://geocode.gate.petersburg.ru/parse/eas', params={
|
||||||
|
'street': street
|
||||||
|
}, timeout=10)
|
||||||
|
|
||||||
|
res = r.json()
|
||||||
|
|
||||||
|
if 'error' in res:
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
return res['Building_ID'], res['Latitude'], res['Longitude']
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_builing_ids(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
df[['ID здания', 'Широта', 'Долгота']] = df.apply(
|
||||||
|
lambda row: get_building_id(row['Улица']), axis=1, result_type='expand')
|
||||||
|
|
||||||
|
return df
|
62
rosseti_parser/preprocess.py
Normal file
62
rosseti_parser/preprocess.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from typing import Any, List
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
COL_NS = {
|
||||||
|
'region': 'Регион РФ (область, край, город фед. значения, округ)',
|
||||||
|
'area': 'Административный район',
|
||||||
|
'town': 'Населённый пункт',
|
||||||
|
'street': 'Улица',
|
||||||
|
'start_date': 'Плановая дата начала отключения электроснабжения',
|
||||||
|
'start_time': 'Плановое время начала отключения электроснабжения',
|
||||||
|
'finish_date': 'Плановая дата восстановления отключения электроснабжения',
|
||||||
|
'finish_time': 'Плановое время восстановления отключения электроснабжения',
|
||||||
|
'branch': 'Филиал',
|
||||||
|
'res': 'РЭС',
|
||||||
|
'comment': 'Комментарий',
|
||||||
|
'building_id': 'ID здания',
|
||||||
|
'lat': 'Широта',
|
||||||
|
'lng': 'Долгота'
|
||||||
|
}
|
||||||
|
|
||||||
|
ICOL_NS = dict(map(reversed, COL_NS.items()))
|
||||||
|
|
||||||
|
|
||||||
|
def preprocess_df(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
df.rename(columns=ICOL_NS, inplace=True)
|
||||||
|
|
||||||
|
for a in ('start', 'finish'):
|
||||||
|
df[f'{a}'] = pd.to_datetime(
|
||||||
|
df[f'{a}_date'].astype(str) + ' ' + df[f'{a}_time'].astype(str),
|
||||||
|
dayfirst=True
|
||||||
|
)
|
||||||
|
df.drop(columns=[f'{a}_date', f'{a}_time'], inplace=True)
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def preprocess_read_df(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
for name in ('start', 'finish'):
|
||||||
|
df[name] = pd.to_datetime(df[name])
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def join_columns(col: pd.Series[Any]) -> List[Any] | Any:
|
||||||
|
first = col.iloc[0]
|
||||||
|
|
||||||
|
if col.name in ('street', 'building_id', 'lat', 'lng') and pd.notnull(first):
|
||||||
|
return list(col)
|
||||||
|
|
||||||
|
return first
|
||||||
|
|
||||||
|
|
||||||
|
def group_by_index(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
groupped = df.groupby('index')
|
||||||
|
|
||||||
|
res_df = groupped.apply(
|
||||||
|
lambda index_df: index_df.apply(join_columns)
|
||||||
|
).drop(columns='index')
|
||||||
|
|
||||||
|
return res_df
|
18
rosseti_parser/util.py
Normal file
18
rosseti_parser/util.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from . import RossetiParser, split_addresses, fetch_builing_ids, preprocess_df
|
||||||
|
|
||||||
|
|
||||||
|
def pipeline(parser: Optional[RossetiParser] = None) -> RossetiParser:
|
||||||
|
if parser is None:
|
||||||
|
parser = RossetiParser()
|
||||||
|
|
||||||
|
print(parser)
|
||||||
|
|
||||||
|
parser.df = split_addresses(parser.df)
|
||||||
|
|
||||||
|
parser.df = fetch_builing_ids(parser.df)
|
||||||
|
|
||||||
|
parser.df = preprocess_df(parser.df)
|
||||||
|
|
||||||
|
return parser
|
Loading…
x
Reference in New Issue
Block a user