розклад записується до БД

This commit is contained in:
Max Lakhman 2025-05-29 16:05:14 +03:00
commit 6a49eb6a03
7 changed files with 350 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
__pycache__/
*.pyc
schedules.db
.env
.venv/
.idea/

35
README.md Normal file
View File

@ -0,0 +1,35 @@
# Бот Розкладу Електричок
Цей проєкт Telegram-бот, який щодня отримує розклад електричок за напрямками Київ→Ніжин та Ніжин→Київ, зберігає дані в SQLite і дозволяє користувачам запитувати розклад командою.
/kyiv — розклад з Києва до Ніжина
/nizhyn — розклад з Ніжина до Києва
## Налаштування
1. Встановити залежності:
```bash
pip install -r requirements.txt
```
2. Для локального тестування (Київ→Ніжин) покладіть `rozklad.html` у корінь проєкту.
3. Встановити токен Telegram-бота:
```bash
export TELEGRAM_TOKEN=<ваш_токен> # Windows CMD: set TELEGRAM_TOKEN=...
```
4. Ініціалізувати базу даних:
```bash
python -c "import db; db.init_db()"
```
5. Перевірити парсер:
```bash
python parser.py
```
6. Запустити планувальник:
```bash
python scheduler.py
```
7. Запустити бота:
```bash
python bot.py
```

56
bot.py Normal file
View File

@ -0,0 +1,56 @@
import os
from telegram import Update
from telegram.constants import ParseMode
from telegram.ext import ApplicationBuilder, CommandHandler, ContextTypes
from db import get_schedule, init_db
TOKEN = os.getenv('TELEGRAM_TOKEN')
def format_schedule(rows):
if not rows:
return 'Розклад на сьогодні недоступний.'
text = ''
for num, dep, arr in rows:
text += f'🚆 {num}: {dep}{arr}\n'
return text
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text(
'Привіт! Я бот розкладу електричок.\n'
'/kyiv — Київ→Ніжин\n'
'/nizhyn — Ніжин→Київ'
)
async def cmd_kyiv(update: Update, context: ContextTypes.DEFAULT_TYPE):
rows = get_schedule('kyiv_nizhyn')
await update.message.reply_text(format_schedule(rows), parse_mode=ParseMode.HTML)
async def cmd_nizhyn(update: Update, context: ContextTypes.DEFAULT_TYPE):
rows = get_schedule('nizhyn_kyiv')
await update.message.reply_text(format_schedule(rows), parse_mode=ParseMode.HTML)
def main():
if not TOKEN:
print("Будь ласка, встановіть змінну оточення TELEGRAM_TOKEN")
return
# Ініціалізуємо БД (створимо таблицю schedules, якщо її ще немає)
init_db()
app = ApplicationBuilder().token(TOKEN).build()
app.add_handler(CommandHandler('start', start))
app.add_handler(CommandHandler('kyiv', cmd_kyiv))
app.add_handler(CommandHandler('nizhyn', cmd_nizhyn))
# Запускаємо бота
app.run_polling()
if __name__ == '__main__':
main()

102
db.py Normal file
View File

@ -0,0 +1,102 @@
import sqlite3
from datetime import date
from typing import List, Dict, Optional
DB_PATH = 'schedules.db'
def init_db():
"""Створює таблиці trains, stations та schedules, якщо їх ще нема."""
with sqlite3.connect(DB_PATH) as con:
con.execute('''
CREATE TABLE IF NOT EXISTS trains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
train_number TEXT UNIQUE NOT NULL,
days TEXT NOT NULL -- бінарна маска (MonSun)
);
''')
con.execute('''
CREATE TABLE IF NOT EXISTS stations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
km REAL
);
''')
con.execute('''
CREATE TABLE IF NOT EXISTS schedules (
train_id INTEGER NOT NULL,
station_id INTEGER NOT NULL,
arrival_time TEXT,
departure_time TEXT,
travel_date DATE NOT NULL,
fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (train_id, station_id, travel_date),
FOREIGN KEY(train_id) REFERENCES trains(id),
FOREIGN KEY(station_id) REFERENCES stations(id)
);
''')
con.commit()
def save_schedule(direction: str, entries: List[Dict]):
"""Зберігає повний розклад для заданого напряму."""
today = date.today().isoformat()
with sqlite3.connect(DB_PATH) as con:
# Видаляємо старі записи за сьогоднішню дату
con.execute('DELETE FROM schedules WHERE travel_date = ?', (today,))
for e in entries:
train_number = e['train_number']
days = e['days']
# Додаємо або оновлюємо поїзд
con.execute('''
INSERT INTO trains (train_number, days)
VALUES (?, ?)
ON CONFLICT(train_number) DO UPDATE SET days = excluded.days
''', (train_number, days))
train_id = con.execute(
'SELECT id FROM trains WHERE train_number = ?', (train_number,)
).fetchone()[0]
for t in e['times']:
station = t['station']
km = t.get('km') # невідомо, можна None
# Додаємо або оновлюємо станцію
con.execute('''
INSERT INTO stations (name, km)
VALUES (?, ?)
ON CONFLICT(name) DO UPDATE SET km = COALESCE(excluded.km, stations.km)
''', (station, km))
station_id = con.execute(
'SELECT id FROM stations WHERE name = ?', (station,)
).fetchone()[0]
arrival = t['arrival']
departure = t['departure']
# Вставляємо або замінюємо розклад поїзда на станції
con.execute('''
INSERT OR REPLACE INTO schedules
(train_id, station_id, arrival_time, departure_time, travel_date)
VALUES (?, ?, ?, ?, ?)
''', (train_id, station_id, arrival, departure, today))
con.commit()
def get_schedule(direction: str, travel_date: Optional[str] = None) -> List[Dict]:
"""Повертає розклад поїздів за датою."""
travel_date = travel_date or date.today().isoformat()
with sqlite3.connect(DB_PATH) as con:
rows = con.execute('''
SELECT tr.train_number, st.name, sc.arrival_time, sc.departure_time
FROM schedules sc
JOIN trains tr ON sc.train_id = tr.id
JOIN stations st ON sc.station_id = st.id
WHERE sc.travel_date = ?
ORDER BY tr.train_number, st.id
''', (travel_date,)).fetchall()
schedule: Dict[str, List[Dict]] = {}
for num, station, arrival, departure in rows:
schedule.setdefault(num, []).append({
'station': station,
'arrival': arrival,
'departure': departure
})
return [{'train_number': num, 'times': times} for num, times in schedule.items()]

114
parser.py Normal file
View File

@ -0,0 +1,114 @@
import requests
from bs4 import BeautifulSoup
from typing import List, Dict
BASE_URL = 'https://swrailway.gov.ua/timetable/eltrain/?rid=2'
LOCAL_HTML = 'rozklad.html'
# Індекс днів тижня: понеділок=0, ..., неділя=6
DAY_INDEX = {
'пн': 0, 'вт': 1, 'ср': 2,
'чт': 3, 'пт': 4, 'сб': 5,
'нд': 6
}
def parse_days(text: str) -> str:
"""Перетворює текст днів курсування на бінарний рядок, довжиною 7."""
text = text.lower().strip()
if 'щоденно' in text:
return '1111111'
if text.startswith('крім'):
# виключні дні
days_part = text.split('крім', 1)[1]
days = [d.strip(' .') for d in days_part.split(',')]
mask = [1] * 7
for d in days:
idx = DAY_INDEX.get(d)
if idx is not None:
mask[idx] = 0
return ''.join(str(b) for b in mask)
if text.startswith('по'):
# тільки вказані дні
days_part = text.split('по', 1)[1]
days = [d.strip(' .') for d in days_part.split(',')]
mask = [0] * 7
for d in days:
idx = DAY_INDEX.get(d)
if idx is not None:
mask[idx] = 1
return ''.join(str(b) for b in mask)
# за замовчуванням — щоденно
return '1111111'
def fetch_schedule(use_local: bool = False) -> List[Dict]:
"""Повертає список поїздів зі станціями та часами:
[
{
'train_number': '6902',
'days': '1111111',
'times': [
{'station': 'Київ-Волинський', 'arrival': '', 'departure': '05:10'},
... 35 записів ...
]
},
...
]
"""
# Завантажуємо HTML
if use_local:
with open(LOCAL_HTML, 'r', encoding='utf-8') as f:
html = f.read()
else:
resp = requests.get(BASE_URL, timeout=10)
resp.raise_for_status()
html = resp.text
soup = BeautifulSoup(html, 'html.parser')
# Список станцій
station_tags = soup.select(
'div#tabs-trains1 table.left tr.on a.et, div#tabs-trains1 table.left tr.onx a.et'
)
stations = [a.get_text(strip=True) for a in station_tags]
# Таблиця з часами
times_table = soup.select_one('div#tabs-trains1 table.td_center')
trs = times_table.find_all('tr')
# Рядок з заголовками поїздів
header_row = next(r for r in trs if r.find('td', class_='on_right_t'))
tds = header_row.find_all('td', class_='on_right_t')
entries: List[Dict] = []
for td in tds:
text = td.get_text(separator='|', strip=True)
parts = text.split('|')
num = parts[0].rstrip(',').strip()
days_text = parts[1].strip() if len(parts) > 1 else 'щоденно'
days = parse_days(days_text)
entries.append({
'train_number': num,
'days': days,
'times': []
})
# Рядки з часами руху (35 рядків)
time_rows = [r for r in trs if r.find('td', class_='q0') or r.find('td', class_='q1')]
# Заповнюємо часи для кожного поїзда та станції
for idx, entry in enumerate(entries):
base = idx * 3
times_list = []
for si, row in enumerate(time_rows):
cells = row.find_all('td')
arrival = cells[base + 1].get_text(strip=True) if base + 1 < len(cells) else ''
departure = cells[base + 2].get_text(strip=True) if base + 2 < len(cells) else ''
times_list.append({
'station': stations[si],
'arrival': arrival,
'departure': departure
})
entry['times'] = times_list
return entries

4
requirements.txt Normal file
View File

@ -0,0 +1,4 @@
requests
beautifulsoup4
python-telegram-bot
APScheduler

33
scheduler.py Normal file
View File

@ -0,0 +1,33 @@
from apscheduler.schedulers.blocking import BlockingScheduler
from parser import fetch_schedule
from db import init_db, save_schedule
from datetime import date
def daily_job():
# Ініціалізуємо базу даних (створюємо таблиці, якщо їх нема)
init_db()
# Отримуємо повний розклад з сайту (Kyiv→Nizhyn)
entries = fetch_schedule(use_local=False)
# Діагностика: виведемо кількість поїздів та список зупинок
print(f"DEBUG: знайдено поїздів: {len(entries)}")
for e in entries:
print(f"DEBUG: Поїзд {e['train_number']} днів {e['days']}, станцій: {len(e['times'])}")
# Зберігаємо розклад у БД (додає поїзди, станції та часи)
save_schedule('kyiv_nizhyn', entries)
print(f"{date.today().isoformat()}: збережено розклад для {len(entries)} поїздів.")
if __name__ == '__main__':
# Налаштування планувальника: щодня о 05:00 (Europe/Kyiv)
sched = BlockingScheduler(timezone='Europe/Kyiv')
sched.add_job(daily_job, 'cron', hour=5, minute=0)
# Одноразове оновлення та діагностика при старті
daily_job()
# Запуск планувальника
sched.start()