Update projektu

This commit is contained in:
lachtan
2026-07-22 12:32:02 +02:00
parent 19014ed3d9
commit 8e66d6b92a
22 changed files with 1995 additions and 503 deletions

View File

@@ -0,0 +1,163 @@
# flight-search
Vyhledávání letenek přes KAYAK s ukládáním výsledků do SQLite.
## Kdy aktivovat
- "hledej letenky", "flight search", "letenky do X", "vyhledej let", "letenky PRG→PTY"
- Když uživatel zadá trasu, datumy a počet osob
## Přehled
Skill orchestruje hledání letenek:
1. Vytvoří definici hledání v DB přes CLI skript
2. Fetchnout KAYAK URL přes `web_fetch`
3. Parsuje výsledky z markdown
4. Uloží nalezené lety do DB
5. Prezentuje top výsledky uživateli
## Postup
### 1. Získat parametry od uživatele
Vyžadované:
- **origin** — IATA kód (např. PRG)
- **destination** — IATA kód (např. PTY)
- **dep_date** — datum odletu (YYYY-MM-DD)
- **ret_date** — datum návratu (YYYY-MM-DD)
- **adults** — počet dospělých (default 1)
Volitelné:
- **flex_days** — ±dny pro flexibilní hledání (default 0, max 3)
- **min_stay_days** — minimální doba pobytu v destinaci (default 1)
- **max_layovers** — max přestupy (default 1, pevně nastaveno)
- **currency** — CZK (default)
### 2. Vytvořit hledání
```bash
uv run skills/flight-search/scripts/flight_search.py create-search \
--origin PRG --destination PTY \
--dep-date 2026-12-21 --ret-date 2027-01-04 \
--adults 3 --flex-days 3 --min-stay-days 14 --max-layovers 1
```
Vrátí JSON s `search_id` a seznamem `urls`.
### 3. Fetch KAYAK stránky
Pro každou URL z `urls` použij `web_fetch` s `extractMode="text"`.
**Důležité**: Používej **cz.kayak.com** doménu (české UI, ceny v CZK). URL formát:
```
https://www.cz.kayak.com/flights/{origin}-{destination}/{dep}/{ret}/{adults}adults?sort=price_a&fs=stops=-2
```
KAYAK URL parametry:
- `sort=price_a` — seřadit podle ceny vzestupně
- `fs=stops=-2` — max 1 přestup (KAYAK počítá "additional stops", -2 = max 1)
- `curr=CZK` — ceny v CZK (na cz.kayak.com je CZK default)
### 4. Parsování výsledků
KAYAK vrací markdown s lety. Hledej vzory:
**Cena** (na cz.kayak.com v CZK):
- `34 484 Kč` nebo `32 232 Kč/osobu` nebo `Celkem 96 695 Kč`
- Pozor: mezera jako oddělovač tisíců, ne čárka
**Aerolinka**:
- `Air France`, `KLM`, `Turkish Airlines`, `SWISS`, `Copa Airlines`, `United Airlines`, atd.
- Může být kombinace: `KLM, Air France`
**Trasa a přestupy**:
- `1 přest.` nebo `2 přest.`
- `CDG Mezipřistání 3hod 05min, Paříž Letiště Charlese de Gaulla`
- `AMS Mezipřistání 16hod 55min, Amsterdam Letiště Schiphol`
**Časy**:
- Odlet: `09:50 20:20` nebo `09:50`
- Přílet: `20:20` nebo `20:20+1` (+1 = další den)
- Doba letu: `16hod 30min` nebo `16h 30m`
**Typický blok výsledku** (cz.kayak.com s fs=stops=-2):
```
1. 09:50 20:20
PRG Letiště Václav Havel - PTY Tocumen Intl 1 přest.
CDG Mezipřistání 3hod 05min, Paříž Letiště Charlese de Gaulla 16hod 30min
2. 22:30 22:40+1
PTY Tocumen Intl - PRG Letiště Václav Havel 1 přest.
CDG Mezipřistání 6hod 25min, Paříž Letiště Charlese de Gaulla 18hod 10min
Air France
1
0
34 484 Kč
/osobu
Celkem 103 452 Kč
Light
```
### 5. Uložit výsledky
Pro každý nalezený let:
```bash
uv run skills/flight-search/scripts/flight_search.py add-result \
--search-id <ID> \
--airline "Air France" \
--route "PRG→CDG→PTY / PTY→CDG→PRG" \
--dep-date 2026-12-21 --ret-date 2027-01-04 \
--dep-time "09:50" --arr-time "20:20" \
--layovers 1 --layover-info "CDG 3h05m" \
--duration "16h30m" \
--price 34484 --price-czk 34484
```
Nebo batch přes stdin:
```bash
echo '[{"search_id":1,"airline":"Air France",...}]' | \
uv run skills/flight-search/scripts/flight_search.py add-results
```
### 6. Prezentovat výsledky
```bash
uv run skills/flight-search/scripts/flight_search.py results <search_id> --top 5
```
Výstup formátuj pro uživatele v češtině s přehlednou tabulkou.
### 7. Historie a správa
```bash
# Seznam hledání
uv run skills/flight-search/scripts/flight_search.py list-searches
# Smazat hledání
uv run skills/flight-search/scripts/flight_search.py delete-search <id>
```
## Poznámky k parsování
- KAYAK stránka může obsahovat reklamy (JustFly, Expedia, FlightHub) — ignoruj
- Některé výsledky mají "Vlastní transfer" (self-transfer) — označ, ale nefiltruj
- Ceny jsou per osoba, celková cena je vždy uvedena jako "Celkem X Kč"
- Na cz.kayak.com jsou ceny automaticky v CZK
- Pokud web_fetch selže nebo vrátí jen navigaci bez výsledků, informuj uživatele a navrhni manuální kontrolu na KAYAKu
- KAYAK může vyžadovat více fetchů pro flex data — každá URL je jedna kombinace datumů
## CLI reference
| Příkaz | Popis |
|--------|-------|
| `create-search` | Vytvoří hledání, vrátí search_id + URL |
| `add-result` | Přidá jeden výsledek |
| `add-results` | Přidá víc výsledků z JSON (stdin nebo --json-file) |
| `results <id>` | Top N výsledků seřazených podle ceny |
| `list-searches` | Seznam všech hledání |
| `delete-search <id>` | Smaže hledání + výsledky |
## DB
Auto-vytvořeno v `db/flight_search.sqlite`. Tabulky:
- `searches` — definice hledání (trasa, datumy, flex, osoby)
- `results` — nalezené lety (aerolinka, trasa, časy, cena, přestupy)

View File

@@ -0,0 +1,416 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""flight-search skill — CLI for managing flight searches and results in SQLite."""
import argparse
import json
import sqlite3
import sys
from datetime import date, timedelta
from pathlib import Path
from typing import Any
DB_PATH = (
Path(__file__).resolve().parent.parent.parent.parent / "db" / "flight_search.sqlite"
)
SCHEMA = """
CREATE TABLE IF NOT EXISTS searches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
origin TEXT NOT NULL,
destination TEXT NOT NULL,
dep_date TEXT NOT NULL,
ret_date TEXT NOT NULL,
adults INTEGER NOT NULL DEFAULT 1,
max_layovers INTEGER NOT NULL DEFAULT 1,
min_stay_days INTEGER NOT NULL DEFAULT 1,
flex_days INTEGER NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'CZK',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
completed_at TEXT
);
CREATE TABLE IF NOT EXISTS results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
search_id INTEGER NOT NULL REFERENCES searches(id) ON DELETE CASCADE,
airline TEXT NOT NULL,
route TEXT NOT NULL,
dep_date TEXT NOT NULL,
ret_date TEXT NOT NULL,
dep_time TEXT,
arr_time TEXT,
layovers INTEGER NOT NULL DEFAULT 0,
layover_info TEXT,
duration TEXT,
price REAL,
price_currency TEXT NOT NULL DEFAULT 'CZK',
price_czk REAL NOT NULL,
booking_url TEXT,
found_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_results_search ON results(search_id);
CREATE INDEX IF NOT EXISTS idx_results_route ON results(route);
"""
def _connect() -> sqlite3.Connection:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
conn.executescript(SCHEMA)
return conn
def _now() -> str:
from datetime import datetime, timezone
return datetime.now(timezone.utc).isoformat()
# ── create-search ──────────────────────────────────────────────────────────
def cmd_create_search(args: argparse.Namespace) -> None:
dep = date.fromisoformat(args.dep_date)
ret = date.fromisoformat(args.ret_date)
flex = args.flex_days
min_stay = args.min_stay_days
adults = args.adults
origin = args.origin.upper()
destination = args.destination.upper()
currency = args.currency.upper()
# Generate date combinations respecting min_stay
dep_range = [dep + timedelta(days=d) for d in range(-flex, flex + 1)]
ret_range = [ret + timedelta(days=d) for d in range(-flex, flex + 1)]
combos = []
for d in dep_range:
for r in ret_range:
if (r - d).days >= min_stay:
combos.append((d, r))
# Sort by proximity to original dates, then by total stay duration
combos.sort(key=lambda dr: (abs((dr[0] - dep).days) + abs((dr[1] - ret).days), (dr[1] - dr[0]).days))
# Cap at 15 URLs
combos = combos[:15]
urls = []
for d, r in combos:
url = (
f"https://www.cz.kayak.com/flights/{origin}-{destination}"
f"/{d.strftime('%Y-%m-%d')}/{r.strftime('%Y-%m-%d')}"
f"/{adults}adults?sort=price_a"
)
if args.max_layovers is not None:
url += f"&fs=stops=-{args.max_layovers + 1}"
urls.append(url)
conn = _connect()
try:
cur = conn.execute(
"""INSERT INTO searches
(origin, destination, dep_date, ret_date, adults, max_layovers,
min_stay_days, flex_days, currency, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?)""",
(
origin,
destination,
args.dep_date,
args.ret_date,
adults,
args.max_layovers if args.max_layovers is not None else 1,
min_stay,
flex,
currency,
_now(),
),
)
search_id = cur.lastrowid
conn.commit()
finally:
conn.close()
output = {
"search_id": search_id,
"origin": origin,
"destination": destination,
"date_combinations": len(combos),
"urls": urls,
}
print(json.dumps(output, indent=2, ensure_ascii=False))
# ── add-result ──────────────────────────────────────────────────────────────
def cmd_add_result(args: argparse.Namespace) -> None:
conn = _connect()
try:
conn.execute(
"""INSERT INTO results
(search_id, airline, route, dep_date, ret_date, dep_time, arr_time,
layovers, layover_info, duration, price, price_currency, price_czk,
booking_url, found_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
args.search_id,
args.airline,
args.route,
args.dep_date,
args.ret_date,
args.dep_time,
args.arr_time,
args.layovers if args.layovers is not None else 0,
args.layover_info,
args.duration,
args.price,
args.price_currency if args.price_currency else "CZK",
args.price_czk if args.price_czk is not None else args.price,
args.booking_url,
_now(),
),
)
conn.commit()
print(json.dumps({"ok": True, "search_id": args.search_id}))
finally:
conn.close()
# ── add-results (batch from JSON) ──────────────────────────────────────────
def cmd_add_results(args: argparse.Namespace) -> None:
if args.json_file:
with open(args.json_file) as f:
data = json.load(f)
else:
data = json.load(sys.stdin)
if not isinstance(data, list):
data = [data]
conn = _connect()
try:
count = 0
for row in data:
conn.execute(
"""INSERT INTO results
(search_id, airline, route, dep_date, ret_date, dep_time, arr_time,
layovers, layover_info, duration, price, price_currency, price_czk,
booking_url, found_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
row.get("search_id"),
row.get("airline"),
row.get("route"),
row.get("dep_date"),
row.get("ret_date"),
row.get("dep_time"),
row.get("arr_time"),
row.get("layovers", 0),
row.get("layover_info"),
row.get("duration"),
row.get("price"),
row.get("price_currency", "CZK"),
row.get("price_czk", row.get("price")),
row.get("booking_url"),
_now(),
),
)
count += 1
conn.commit()
print(json.dumps({"ok": True, "added": count}))
finally:
conn.close()
# ── results ─────────────────────────────────────────────────────────────────
def cmd_results(args: argparse.Namespace) -> None:
conn = _connect()
try:
rows = conn.execute(
"""SELECT r.*, s.origin, s.destination, s.adults
FROM results r
JOIN searches s ON s.id = r.search_id
WHERE r.search_id = ?
ORDER BY r.price_czk ASC
LIMIT ?""",
(args.search_id, args.top or 5),
).fetchall()
if not rows:
print("(no results found)")
return
for i, row in enumerate(rows, 1):
layover = ""
if row["layover_info"]:
layover = f" ({row['layover_info']})"
stops = f"{row['layovers']} stop{'' if row['layovers'] == 1 else 's'}"
price_str = f"{row['price_czk']:,.0f} {row['price_currency']}"
if row["price_currency"] != "CZK" and row["price"] != row["price_czk"]:
price_str = f"{row['price']:,.0f} {row['price_currency']}{row['price_czk']:,.0f} CZK"
print(
f"#{i} {row['airline']} {row['route']} "
f"{row['dep_date']}{row['ret_date']} "
f"{row['duration'] or '?'} {stops}{layover} {price_str}"
)
if row["booking_url"]:
print(f" {row['booking_url']}")
finally:
conn.close()
# ── list-searches ───────────────────────────────────────────────────────────
def cmd_list_searches(args: argparse.Namespace) -> None:
conn = _connect()
try:
status_filter = args.status or "active"
if status_filter == "all":
rows = conn.execute(
"SELECT * FROM searches ORDER BY created_at DESC"
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM searches WHERE status = ? ORDER BY created_at DESC",
(status_filter,),
).fetchall()
if not rows:
print("(no searches)")
return
for row in rows:
result_count = conn.execute(
"SELECT COUNT(*) FROM results WHERE search_id = ?", (row["id"],)
).fetchone()[0]
print(
f"#{row['id']} {row['origin']}{row['destination']} "
f"{row['dep_date']}{row['ret_date']} "
f"{row['adults']} adults flex ±{row['flex_days']}d "
f"min stay {row['min_stay_days']}d "
f"max {row['max_layovers']} stop(s) "
f"[{row['status']}] {result_count} results"
)
finally:
conn.close()
# ── delete-search ───────────────────────────────────────────────────────────
def cmd_delete_search(args: argparse.Namespace) -> None:
conn = _connect()
try:
# Check exists
row = conn.execute(
"SELECT id, origin, destination FROM searches WHERE id = ?",
(args.search_id,),
).fetchone()
if not row:
print(json.dumps({"error": f"search {args.search_id} not found"}))
sys.exit(1)
result_count = conn.execute(
"SELECT COUNT(*) FROM results WHERE search_id = ?", (args.search_id,)
).fetchone()[0]
conn.execute("DELETE FROM results WHERE search_id = ?", (args.search_id,))
conn.execute("DELETE FROM searches WHERE id = ?", (args.search_id,))
conn.commit()
print(
json.dumps(
{
"deleted": True,
"search_id": args.search_id,
"route": f"{row['origin']}{row['destination']}",
"results_removed": result_count,
}
)
)
finally:
conn.close()
# ── main ────────────────────────────────────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(description="Flight search CLI")
sub = parser.add_subparsers(dest="command", required=True)
# create-search
p_create = sub.add_parser("create-search", help="Create a new flight search")
p_create.add_argument("--origin", required=True, help="Origin IATA code (e.g. PRG)")
p_create.add_argument("--destination", required=True, help="Destination IATA code (e.g. PTY)")
p_create.add_argument("--dep-date", required=True, help="Departure date YYYY-MM-DD")
p_create.add_argument("--ret-date", required=True, help="Return date YYYY-MM-DD")
p_create.add_argument("--adults", type=int, default=1, help="Number of adults (default 1)")
p_create.add_argument("--max-layovers", type=int, default=None, help="Max additional stops (default 1)")
p_create.add_argument("--min-stay-days", type=int, default=1, help="Minimum stay in days (default 1)")
p_create.add_argument("--flex-days", type=int, default=0, help="±days for flexible dates (default 0)")
p_create.add_argument("--currency", default="CZK", help="Currency code (default CZK)")
# add-result
p_add = sub.add_parser("add-result", help="Add a single flight result")
p_add.add_argument("--search-id", type=int, required=True)
p_add.add_argument("--airline", required=True)
p_add.add_argument("--route", required=True, help="e.g. PRG→AMS→PTY / PTY→AMS→PRG")
p_add.add_argument("--dep-date", required=True)
p_add.add_argument("--ret-date", required=True)
p_add.add_argument("--dep-time", default=None)
p_add.add_argument("--arr-time", default=None)
p_add.add_argument("--layovers", type=int, default=None)
p_add.add_argument("--layover-info", default=None, help="e.g. AMS 2h15m")
p_add.add_argument("--duration", default=None, help="e.g. 12h30m")
p_add.add_argument("--price", type=float, required=True)
p_add.add_argument("--price-currency", default=None)
p_add.add_argument("--price-czk", type=float, default=None)
p_add.add_argument("--booking-url", default=None)
# add-results (batch)
p_batch = sub.add_parser("add-results", help="Add multiple results from JSON")
p_batch.add_argument("--json-file", default=None, help="Path to JSON file (default: stdin)")
# results
p_res = sub.add_parser("results", help="Show results for a search")
p_res.add_argument("search_id", type=int)
p_res.add_argument("--top", type=int, default=5, help="Show top N results (default 5)")
# list-searches
p_list = sub.add_parser("list-searches", help="List all searches")
p_list.add_argument("--status", default="active", help="Filter by status (active/completed/all)")
# delete-search
p_del = sub.add_parser("delete-search", help="Delete a search and its results")
p_del.add_argument("search_id", type=int)
args = parser.parse_args()
commands = {
"create-search": cmd_create_search,
"add-result": cmd_add_result,
"add-results": cmd_add_results,
"results": cmd_results,
"list-searches": cmd_list_searches,
"delete-search": cmd_delete_search,
}
commands[args.command](args)
if __name__ == "__main__":
main()