commit 6a49eb6a0323763ceecd2cc239cfaf2a8d4dbdec Author: zik Date: Thu May 29 16:05:14 2025 +0300 розклад записується до БД diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f992c9b --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +schedules.db +.env +.venv/ +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ebe496 --- /dev/null +++ b/README.md @@ -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 + ``` + \ No newline at end of file diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..8cc88f9 --- /dev/null +++ b/bot.py @@ -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() diff --git a/db.py b/db.py new file mode 100644 index 0000000..00e950b --- /dev/null +++ b/db.py @@ -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 -- бінарна маска (Mon→Sun) + ); + ''') + 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()] diff --git a/parser.py b/parser.py new file mode 100644 index 0000000..5fb5d91 --- /dev/null +++ b/parser.py @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..fb19abe --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +requests +beautifulsoup4 +python-telegram-bot +APScheduler diff --git a/scheduler.py b/scheduler.py new file mode 100644 index 0000000..1e525b3 --- /dev/null +++ b/scheduler.py @@ -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()