find-flight 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/airports.json +1 -0
- package/airports.py +80 -0
- package/countries.json +1 -0
- package/examples/search_flights.py +23 -1
- package/extensions/flight-search.ts +75 -25
- package/flight_runner.py +174 -42
- package/package.json +5 -2
- package/skills/find-flight/SKILL.md +16 -5
package/airports.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Lightweight airport & country lookup for the find-flight runner.
|
|
3
|
+
|
|
4
|
+
Given a 3-letter IATA airport code, resolve the authoritative name, city,
|
|
5
|
+
state/region and ISO-3166-1 alpha-2 country code (and its English country
|
|
6
|
+
name). This is the disambiguation source that prevents mis-mapping a code to
|
|
7
|
+
the wrong city — e.g. ECN is Ercan (North Cyprus), not Edinburgh; MYJ is
|
|
8
|
+
Matsuyama (Japan), not Mysore.
|
|
9
|
+
|
|
10
|
+
Data comes from the bundled JSON files generated from the upstream airport
|
|
11
|
+
list (`enums/airports.csv` in AWeirdDev/flights, ~9.7k rows):
|
|
12
|
+
* airports.json { code: [name, city, state, country_id] }
|
|
13
|
+
* countries.json { ISO2: "English country name" }
|
|
14
|
+
|
|
15
|
+
The files resolve relative to THIS module, so the lookup works regardless of
|
|
16
|
+
the process CWD (important because the extension runs the runner from an
|
|
17
|
+
arbitrary directory). If a file is missing or a code is unknown, the lookup
|
|
18
|
+
returns a minimal record built from the code itself rather than raising, so
|
|
19
|
+
the search never hard-fails on metadata.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
|
|
26
|
+
_THIS = os.path.dirname(os.path.abspath(__file__))
|
|
27
|
+
|
|
28
|
+
_AIRPORTS = None # cached dict: code -> [name, city, state, country]
|
|
29
|
+
_COUNTRIES = None # cached dict: ISO2 -> "English name"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _load(path: str):
|
|
33
|
+
try:
|
|
34
|
+
with open(os.path.join(_THIS, path), encoding="utf-8") as fh:
|
|
35
|
+
return json.load(fh)
|
|
36
|
+
except (OSError, ValueError):
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _airports() -> dict:
|
|
41
|
+
global _AIRPORTS
|
|
42
|
+
if _AIRPORTS is None:
|
|
43
|
+
_AIRPORTS = _load("airports.json") or {}
|
|
44
|
+
return _AIRPORTS
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _countries() -> dict:
|
|
48
|
+
global _COUNTRIES
|
|
49
|
+
if _COUNTRIES is None:
|
|
50
|
+
_COUNTRIES = _load("countries.json") or {}
|
|
51
|
+
return _COUNTRIES
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def country_name(iso2: str) -> str:
|
|
55
|
+
"""ISO-3166-1 alpha-2 code -> English country name ('' if unknown)."""
|
|
56
|
+
return _countries().get((iso2 or "").upper(), "")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def resolve(code: str) -> dict:
|
|
60
|
+
"""Resolve a 3-letter IATA code to name/city/state/country metadata.
|
|
61
|
+
|
|
62
|
+
Never raises: unknown/empty codes return a record keyed on the code with
|
|
63
|
+
empty fields, so callers can still proceed (or choose to reject on 3-char
|
|
64
|
+
validation elsewhere).
|
|
65
|
+
"""
|
|
66
|
+
code = (code or "").strip().upper()
|
|
67
|
+
rec = _airports().get(code, [])
|
|
68
|
+
name, city, state, iso2 = (rec + ["", "", "", ""])[:4]
|
|
69
|
+
return {
|
|
70
|
+
"code": code,
|
|
71
|
+
"name": name,
|
|
72
|
+
"city": city,
|
|
73
|
+
"state": state,
|
|
74
|
+
"country": iso2.upper(),
|
|
75
|
+
"country_name": country_name(iso2),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_known(code: str) -> bool:
|
|
80
|
+
return (code or "").strip().upper() in _airports()
|
package/countries.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"AD":"Andorra","AE":"United","Arab":"Emirates","AF":"Afghanistan","AG":"Antigua","and":"the","AI":"Anguilla","AL":"Albania","AM":"Armenia","AO":"Angola","AQ":"Antarctica","AR":"Argentina","AS":"American","Samoa":"YE","Austria":"AU","Australia":"AW","Aruba":"AX","Aland":"Islands","AZ":"Azerbaijan","BA":"Bosnia","BB":"Barbados","BD":"Bangladesh","BE":"Belgium","BF":"Burkina","Faso":"BG","Bulgaria":"BH","Bahrain":"BI","Burundi":"BJ","Benin":"BL","Saint":"Vincent","BM":"Bermuda","BN":"Brunei","Darussalam":"BO","Bolivia":"BQ","Bonaire":"(Caribbean","Netherlands)":"BR","Brazil":"BS","Bahamas":"BT","Bhutan":"BV","Bouvet":"Island","BW":"Botswana","BY":"Belarus","BZ":"Belize","CA":"Canada","CC":"Cocos","(Keeling)":"Islands","CD":"Democratic","Republic":"DE","the":"Congo","CF":"Central","African":"Republic","CG":"Republic","of":"the","Congo":"CH","Switzerland":"CI","Cote":"d'Ivoire","CK":"Cook","Islands":"VN","Chile":"CM","Cameroon":"CN","China":"TZ","Colombia":"CR","Costa":"Rica","CU":"Cuba","CV":"Cape","Verde":"CW","Curacao":"CX","Christmas":"Island","CY":"Cyprus","CZ":"Czech","Germany":"DJ","Djibouti":"DK","Denmark":"DM","Dominica":"DO","Dominican":"Republic","DZ":"Algeria","EC":"Ecuador","EE":"Estonia","EG":"Egypt","EH":"Western","Sahara":"ER","Eritrea":"ES","Spain":"ET","Ethiopia":"FI","Finland":"FJ","Fiji":"FK","Falkland":"Islands","(Malvinas)":"FM","Micronesia":"FO","Faroe":"Islands","FR":"France","GA":"Gabon","GB":"United","Kingdom":"GD","Grenada":"GE","Georgia":"GF","French":"Guiana","GG":"Guernsey","GH":"Ghana","GI":"Gibraltar","GL":"Greenland","GM":"Gambia","GN":"Guinea","GP":"Guadeloupe","GQ":"Equatorial","Guinea":"PH","Greece":"GS","South":"Africa","Guatemala":"GU","Guam":"GW","Guinea-Bissau":"GY","Guyana":"HK","Hong":"Kong","HM":"Heard","Honduras":"HR","Croatia":"HT","Haiti":"HU","Hungary":"ID","Indonesia":"IE","Ireland":"IL","Israel":"IM","Isle":"of","Man":"IN","India":"IO","British":"Virgin","Ocean":"Territory","IQ":"Iraq","IR":"Iran","IS":"Iceland","IT":"Italy","JE":"Jersey","JM":"Jamaica","JO":"Jordan","JP":"Japan","KE":"Kenya","KG":"Kyrgyzstan","KH":"Cambodia","KI":"Kiribati","KM":"Comoros","KN":"Saint","Kitts":"and","Nevis":"KP","North":"Korea","KR":"South","Korea":"KW","Kuwait":"KY","Cayman":"Islands","KZ":"Kazakhstan","LA":"Laos","LB":"Lebanon","LC":"Saint","Lucia":"LI","Liechtenstein":"LK","Sri":"Lanka","LR":"Liberia","LS":"Lesotho","LT":"Lithuania","LU":"Luxembourg","LV":"Latvia","LY":"Libya","MA":"Morocco","MC":"Monaco","MD":"Moldova","ME":"Montenegro","MF":"Saint","Martin":"MG","Madagascar":"MH","Marshall":"Islands","MK":"North","Macedonia":"ML","Mali":"MM","Myanmar":"MN","Mongolia":"MO","Macao":"MP","Northern":"Mariana","Martinique":"MR","Mauritania":"MS","Montserrat":"MT","Malta":"MU","Mauritius":"MV","Maldives":"MW","Malawi":"MX","Mexico":"MY","Malaysia":"MZ","Mozambique":"NA","Namibia":"NC","New":"Zealand","NE":"Niger","NF":"Norfolk","Island":"NG","Nigeria":"NI","Nicaragua":"NL","Netherlands":"NO","Norway":"NP","Nepal":"NR","Nauru":"NU","Niue":"NZ","OM":"Oman","PA":"Panama","PE":"Peru","PF":"French","Polynesia":"PG","Papua":"New","Philippines":"PK","Pakistan":"PL","Poland":"PM","PN":"Pitcairn","PR":"Puerto","Rico":"PS","Palestine":"(West","Bank":"and","Gaza)":"PT","Portugal":"PW","Palau":"PY","Paraguay":"QA","Qatar":"RE","Reunion":"RO","Romania":"RS","Serbia":"RU","Russia":"RW","Rwanda":"SA","Saudi":"Arabia","SB":"Solomon","Seychelles":"SD","Sudan":"SE","Sweden":"SG","Singapore":"SH","SI":"Slovenia","SJ":"Svalbard","Mayen":"SK","Slovakia":"SL","Sierra":"Leone","SM":"San","Marino":"SN","Senegal":"SO","Somalia":"SR","Suriname":"SS","ST":"Sao","Tome":"and","Principe":"SV","El":"Salvador","SX":"Sint","Maarten":"SY","Syria":"SZ","Swaziland":"TC","Turks":"and","Caicos":"Islands","TD":"Chad","TF":"French","Southern":"Territories","TG":"Togo","TH":"Thailand","TJ":"Tajikistan","TK":"Tokelau","TL":"Timor-Leste","TM":"Turkmenistan","TN":"Tunisia","TO":"Tonga","TR":"Turkey","TT":"Trinidad","TV":"Tuvalu","TW":"Taiwan,","Province":"of","Tanzania":"UA","Ukraine":"UG","Uganda":"UM","U.S.":"Virgin","Outlying":"Islands","US":"United","States":"UY","Uruguay":"UZ","Uzbekistan":"VA","Holy":"See","(Vatican":"City","State)":"VC","Grenadines":"VE","Venezuela":"VG","Vietnam":"VU","Vanuatu":"WF","Wallis":"and","Futuna":"WS","Yemen":"YT","Mayotte":"ZA","ZM":"Zambia","ZW":"Zimbabwe"}
|
|
@@ -90,6 +90,28 @@ def total_duration(flight) -> int:
|
|
|
90
90
|
return sum(seg.duration for seg in flight.flights)
|
|
91
91
|
|
|
92
92
|
|
|
93
|
+
def load_airports():
|
|
94
|
+
"""Try to ""import airports"" for city/country metadata; fall back to a no-op."""
|
|
95
|
+
try:
|
|
96
|
+
import airports as a
|
|
97
|
+
return a
|
|
98
|
+
except Exception:
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
AP = load_airports()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def place(code: str) -> str:
|
|
106
|
+
if AP:
|
|
107
|
+
r = AP.resolve(code)
|
|
108
|
+
if r["country"]:
|
|
109
|
+
return f"{code} {r['city']}, {r['country']}".strip()
|
|
110
|
+
if r["name"]:
|
|
111
|
+
return f"{code} ({r['name']})"
|
|
112
|
+
return code
|
|
113
|
+
|
|
114
|
+
|
|
93
115
|
def print_result(result: ResultList, currency: str = "") -> None:
|
|
94
116
|
if not result:
|
|
95
117
|
print("No flights found.")
|
|
@@ -100,7 +122,7 @@ def print_result(result: ResultList, currency: str = "") -> None:
|
|
|
100
122
|
for seg in flight.flights:
|
|
101
123
|
dep = f"{seg.departure.date[0]}-{seg.departure.date[1]:02d}-{seg.departure.date[2]:02d} {seg.departure.time[0]:02d}:{seg.departure.time[1]:02d}"
|
|
102
124
|
arr = f"{seg.arrival.date[0]}-{seg.arrival.date[1]:02d}-{seg.arrival.date[2]:02d} {seg.arrival.time[0]:02d}:{seg.arrival.time[1]:02d}"
|
|
103
|
-
print(f" {seg.from_airport.code} {dep} -> {seg.to_airport.code} {arr} "
|
|
125
|
+
print(f" {place(seg.from_airport.code)} {dep} -> {place(seg.to_airport.code)} {arr} "
|
|
104
126
|
f"({seg.duration} min, {seg.plane_type})")
|
|
105
127
|
print(f"\n{len(result)} itinerary/ies")
|
|
106
128
|
|
|
@@ -88,11 +88,11 @@ const search_flights = {
|
|
|
88
88
|
name: "search_flights",
|
|
89
89
|
label: "Search Flights",
|
|
90
90
|
description:
|
|
91
|
-
"Search real Google Flights availability between two airports on given dates via fast-flights. Returns structured JSON: price (currency), airline(s), number of stops, and each flight leg with airports, times, duration and plane type. For round-trips
|
|
91
|
+
"Search real Google Flights availability between two airports on given dates via fast-flights. Pass 3-letter IATA airport codes (from_airport/to_airport); the tool resolves each code to its airport name, city and ISO-3166 country so you know exactly which city the code refers to before reading results. Returns structured JSON: the resolved route (airport/city/country), price (currency), airline(s), number of stops, and each flight leg with airports, city/country, times, duration and plane type. For round-trips it runs a native round-trip query and returns the TRUE two-way total price with the outbound legs, plus one-way reference fares showing return-leg schedules (return_options). Use for finding flights, checking prices/availability, comparing routes, dates, seats, or airlines.",
|
|
92
92
|
promptSnippet: "Search live Google Flights for price/availability between airports",
|
|
93
93
|
promptGuidelines: [
|
|
94
94
|
"Use search_flights for real flight prices and availability instead of guessing or writing ad-hoc scraping scripts.",
|
|
95
|
-
"
|
|
95
|
+
"Pass 3-letter IATA airport codes (from_airport/to_airport); the tool resolves each code to its airport name, city and ISO-3166 country so you can confirm a code really means the city the user wants (e.g. ECN = Ercan/Cyprus). Resolve dates to YYYY-MM-DD; ask the user if the code, trip type, or dates are unclear.",
|
|
96
96
|
"search_flights prices carry the currency you pass; pass a currency (e.g. 'EUR', 'TRY', 'USD') unless the user prefers Google's default.",
|
|
97
97
|
],
|
|
98
98
|
parameters: Type.Object({
|
|
@@ -137,34 +137,84 @@ const search_flights = {
|
|
|
137
137
|
const obj = data as Record<string, unknown>;
|
|
138
138
|
if (obj.error) throw new Error(`flight search error: ${obj.error}`);
|
|
139
139
|
|
|
140
|
-
const
|
|
141
|
-
|
|
140
|
+
const currency = (obj.currency as string | undefined) ?? "";
|
|
141
|
+
|
|
142
|
+
// Human-friendly duration: show minutes too when under 2h (e.g. 1h 35m).
|
|
143
|
+
const fmtDuration = (min: number) => {
|
|
144
|
+
const m = Math.round(min || 0);
|
|
145
|
+
const h = Math.floor(m / 60);
|
|
146
|
+
const r = m % 60;
|
|
147
|
+
return h > 0 ? `${h}h${r ? ` ${r}m` : ""}` : `${r}m`;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// Render one direction of flights (outbound or return).
|
|
151
|
+
const renderDirection = (
|
|
152
|
+
label: string,
|
|
153
|
+
flights: Array<Record<string, unknown>>,
|
|
154
|
+
routeSummary?: string,
|
|
155
|
+
) => {
|
|
156
|
+
const sorted = [...flights].sort((a, b) => Number(a.price) - Number(b.price));
|
|
157
|
+
const top = sorted.slice(0, 10);
|
|
158
|
+
const header = `## ${label}${routeSummary ? ` — ${routeSummary}` : ""}`;
|
|
159
|
+
if (top.length === 0) return `${header}\nNo ${label.toLowerCase()} flights found.`;
|
|
160
|
+
const lines = top.map((f, i) => {
|
|
161
|
+
const legs = (f.legs as Array<Record<string, string>>) ?? [];
|
|
162
|
+
const where = (l: Record<string, string>, prefix: "from" | "to") => {
|
|
163
|
+
const code = l[prefix];
|
|
164
|
+
const city = l[`${prefix}_city`] ?? "";
|
|
165
|
+
const country = l[`${prefix}_country`] ?? "";
|
|
166
|
+
const ctx = city || country ? ` (${city}, ${country})` : "";
|
|
167
|
+
return `${code}${ctx}`;
|
|
168
|
+
};
|
|
169
|
+
const seg = legs
|
|
170
|
+
.map(
|
|
171
|
+
(l) =>
|
|
172
|
+
`${where(l, "from")} ${l.departure} → ${where(l, "to")} ${l.arrival} ${l.date} ` +
|
|
173
|
+
`(${fmtDuration(Number(l.duration_minutes) || 0)}, ${l.plane})`,
|
|
174
|
+
)
|
|
175
|
+
.join(", ");
|
|
176
|
+
const airline = (f.airlines as string[] | undefined)?.join("/") ?? "";
|
|
177
|
+
const stops = Number(f.stops) || 0;
|
|
178
|
+
return `${i + 1}. ${f.currency ?? currency} ${f.price} · ${airline} · ${stops === 0 ? "direct" : `${stops} stop${stops > 1 ? "s" : ""}`}\n ${seg}`;
|
|
179
|
+
});
|
|
180
|
+
return `${header}\n${lines.join("\n")}\n\n${sorted.length} itinerary/ies (showing cheapest ${top.length})`;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// ---- round-trip: native RT query (true two-way price) + return reference ----
|
|
184
|
+
if (obj.trip === "round-trip") {
|
|
185
|
+
const rt = (obj.round_trip ?? []) as Array<Record<string, unknown>>;
|
|
186
|
+
const retOpts = (obj.return_options ?? []) as Array<Record<string, unknown>>;
|
|
187
|
+
const or = obj.outbound_route as Record<string, unknown> | undefined;
|
|
188
|
+
const rr = obj.return_route as Record<string, unknown> | undefined;
|
|
189
|
+
const outboundSummary = (or?.summary as string | undefined) ?? "";
|
|
190
|
+
const returnSummary = (rr?.summary as string | undefined) ?? "";
|
|
191
|
+
const parts: string[] = [];
|
|
192
|
+
parts.push(renderDirection("Round-trip (true two-way total, outbound legs shown)", rt, outboundSummary));
|
|
193
|
+
parts.push(renderDirection("Return leg options (one-way reference fares — NOT round-trip prices)", retOpts, returnSummary));
|
|
194
|
+
const text = parts.join("\n\n");
|
|
142
195
|
return {
|
|
143
|
-
content: [{ type: "text", text
|
|
144
|
-
details: {
|
|
196
|
+
content: [{ type: "text", text }],
|
|
197
|
+
details: {
|
|
198
|
+
currency,
|
|
199
|
+
trip: "round-trip",
|
|
200
|
+
outbound_route: or,
|
|
201
|
+
return_route: rr,
|
|
202
|
+
round_trip: rt,
|
|
203
|
+
return_options: retOpts,
|
|
204
|
+
note: obj.note,
|
|
205
|
+
},
|
|
145
206
|
};
|
|
146
207
|
}
|
|
147
|
-
flights.sort((a, b) => Number(a.price) - Number(b.price));
|
|
148
|
-
const top = flights.slice(0, 10);
|
|
149
|
-
|
|
150
|
-
const lines = top.map((f, i) => {
|
|
151
|
-
const legs = (f.legs as Array<Record<string, string>>) ?? [];
|
|
152
|
-
const seg = legs.map(
|
|
153
|
-
(l) =>
|
|
154
|
-
`${l.from} ${l.departure} → ${l.to} ${l.arrival} ${l.date} (${Math.round(
|
|
155
|
-
(Number(l.duration_minutes) || 0) / 60,
|
|
156
|
-
)}h, ${l.plane})`,
|
|
157
|
-
).join(", ");
|
|
158
|
-
const rt = f.is_round_trip ? " (round-trip total)" : "";
|
|
159
|
-
const airline = (f.airlines as string[] | undefined)?.join("/") ?? "";
|
|
160
|
-
const stops = Number(f.stops) || 0;
|
|
161
|
-
return `${i + 1}. ${f.currency} ${f.price}${rt} · ${airline} · ${stops === 0 ? "direct" : `${stops} stop`}\n ${seg}`;
|
|
162
|
-
});
|
|
163
|
-
const summary = `${lines.join("\n")}\n\n${flights.length} itinerary/ies (showing cheapest ${top.length})`;
|
|
164
208
|
|
|
209
|
+
// ---- one-way (and legacy flat shape) ----
|
|
210
|
+
const route = obj.route as Record<string, unknown> | undefined;
|
|
211
|
+
const routeSummary = (route?.summary as string | undefined) ?? "";
|
|
212
|
+
const flights = (obj.flights ?? []) as Array<Record<string, unknown>>;
|
|
213
|
+
const rendered = renderDirection("Flights", flights, routeSummary);
|
|
214
|
+
const text = `${rendered}\n\nRoute: ${routeSummary}`;
|
|
165
215
|
return {
|
|
166
|
-
content: [{ type: "text", text
|
|
167
|
-
details: { currency:
|
|
216
|
+
content: [{ type: "text", text }],
|
|
217
|
+
details: { currency, trip: "one-way", route, flights },
|
|
168
218
|
};
|
|
169
219
|
},
|
|
170
220
|
};
|
package/flight_runner.py
CHANGED
|
@@ -21,20 +21,44 @@ Query keys (all optional unless noted):
|
|
|
21
21
|
Errors are reported as JSON with {"error": "..."} and a non-zero exit code, so
|
|
22
22
|
the caller can distinguish "no flights" (valid empty result) from a failure.
|
|
23
23
|
|
|
24
|
-
Output
|
|
25
|
-
[{ "price": int, "currency": str, "is_round_trip": bool, "airlines": [..],
|
|
26
|
-
"type": str, "carbon": {"typical_on_route": int, "emission": int},
|
|
27
|
-
"stops": int, "legs": [leg...] }]
|
|
24
|
+
Output shapes:
|
|
28
25
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
26
|
+
one-way -> { currency, trip: "one-way", route, flights: [flight...] }
|
|
27
|
+
round-trip-> { currency, trip: "round-trip", outbound_route, return_route,
|
|
28
|
+
round_trip: [flight...], return_options: [flight...], note }
|
|
29
|
+
|
|
30
|
+
Each flight: { "price": int, "currency": str, "price_type": str,
|
|
31
|
+
"airlines": [..], "type": str,
|
|
32
|
+
"carbon": {"typical_on_route": int, "emission": int},
|
|
33
|
+
"stops": int, "legs": [leg...] }
|
|
34
|
+
|
|
35
|
+
ROUND-TRIP PRICING (fast-flights behavior, verified 2026-09-11): a single
|
|
36
|
+
native round-trip query (two FlightQuery legs, trip="round-trip") returns
|
|
37
|
+
flight.price as the TRUE two-way total fare, and flight.flights with ONLY the
|
|
38
|
+
outbound legs — the return direction is not in the data (no booking token
|
|
39
|
+
links a specific outbound with a specific return). NEVER estimate a
|
|
40
|
+
round-trip price by summing two one-way fares: one-way fares (especially
|
|
41
|
+
business) are often far higher than half the RT fare (e.g. IST<->BRU direct
|
|
42
|
+
business 2026-11-20/22 was USD 855 RT vs ~USD 1422 as a sum of one-ways).
|
|
43
|
+
|
|
44
|
+
So for round-trips the runner:
|
|
45
|
+
1. runs the native RT query -> `round_trip[]`: true two-way totals with
|
|
46
|
+
outbound legs (price_type "round-trip-total") — this is the price to
|
|
47
|
+
present;
|
|
48
|
+
2. runs ONE one-way search for the return direction -> `return_options[]`:
|
|
49
|
+
real return-leg schedules priced at one-way fares for reference ONLY
|
|
50
|
+
(price_type "one-way-reference").
|
|
34
51
|
|
|
35
52
|
Each leg: { "from": code, "from_name": str, "to": code, "to_name": str,
|
|
53
|
+
"from_city": str, "to_city": str,
|
|
54
|
+
"from_country": str, "to_country": str,
|
|
55
|
+
"from_country_name": str, "to_country_name": str,
|
|
36
56
|
"date": "YYYY-MM-DD", "departure": "HH:MM", "arrival": "HH:MM",
|
|
37
57
|
"duration_minutes": int, "plane": str }
|
|
58
|
+
|
|
59
|
+
Metadata: airport names/cities and ISO-3166 country codes are resolved from
|
|
60
|
+
the bundled lookup (airports.py + airports.json/countries.json) so the caller
|
|
61
|
+
knows exactly which city and country each code refers to (disambiguation).
|
|
38
62
|
"""
|
|
39
63
|
from __future__ import annotations
|
|
40
64
|
|
|
@@ -49,6 +73,8 @@ from fast_flights import (
|
|
|
49
73
|
get_flights,
|
|
50
74
|
)
|
|
51
75
|
|
|
76
|
+
import airports as airport_lookup
|
|
77
|
+
|
|
52
78
|
SEATS = ("economy", "premium-economy", "business", "first")
|
|
53
79
|
TRIPS = ("one-way", "round-trip")
|
|
54
80
|
|
|
@@ -58,11 +84,21 @@ def _hhmm(t) -> str:
|
|
|
58
84
|
|
|
59
85
|
|
|
60
86
|
def _leg(seg) -> dict:
|
|
87
|
+
f = airport_lookup.resolve(seg.from_airport.code)
|
|
88
|
+
t = airport_lookup.resolve(seg.to_airport.code)
|
|
61
89
|
return {
|
|
62
90
|
"from": seg.from_airport.code,
|
|
63
|
-
"from_name": seg.from_airport.name,
|
|
91
|
+
"from_name": f["name"] or seg.from_airport.name,
|
|
92
|
+
"from_city": f["city"],
|
|
93
|
+
"from_state": f["state"],
|
|
94
|
+
"from_country": f["country"],
|
|
95
|
+
"from_country_name": f["country_name"],
|
|
64
96
|
"to": seg.to_airport.code,
|
|
65
|
-
"to_name": seg.to_airport.name,
|
|
97
|
+
"to_name": t["name"] or seg.to_airport.name,
|
|
98
|
+
"to_city": t["city"],
|
|
99
|
+
"to_state": t["state"],
|
|
100
|
+
"to_country": t["country"],
|
|
101
|
+
"to_country_name": t["country_name"],
|
|
66
102
|
"date": "%04d-%02d-%02d" % seg.departure.date,
|
|
67
103
|
"departure": _hhmm(seg.departure.time),
|
|
68
104
|
"arrival": _hhmm(seg.arrival.time),
|
|
@@ -71,32 +107,10 @@ def _leg(seg) -> dict:
|
|
|
71
107
|
}
|
|
72
108
|
|
|
73
109
|
|
|
74
|
-
def
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
raise ValueError(f"seat must be one of {SEATS}")
|
|
79
|
-
if trip not in TRIPS:
|
|
80
|
-
raise ValueError(f"trip must be one of {TRIPS}")
|
|
81
|
-
|
|
82
|
-
outbound_date = q["outbound_date"]
|
|
83
|
-
src = q["from_airport"].strip().upper()
|
|
84
|
-
dst = q["to_airport"].strip().upper()
|
|
85
|
-
for code in (src, dst):
|
|
86
|
-
if not (code.isalpha() and len(code) == 3):
|
|
87
|
-
raise ValueError(f"airport code must be 3 letters, got {code!r}")
|
|
88
|
-
|
|
89
|
-
legs = [FlightQuery(date=outbound_date, from_airport=src, to_airport=dst)]
|
|
90
|
-
return_date = q.get("return_date")
|
|
91
|
-
if trip == "round-trip":
|
|
92
|
-
if not return_date:
|
|
93
|
-
raise ValueError("return_date is required for round-trip")
|
|
94
|
-
legs.append(FlightQuery(date=return_date, from_airport=dst, to_airport=src))
|
|
95
|
-
|
|
96
|
-
query = create_query(
|
|
97
|
-
flights=legs,
|
|
98
|
-
trip=trip,
|
|
99
|
-
seat=seat,
|
|
110
|
+
def _query_params(q: dict) -> dict:
|
|
111
|
+
"""Common fast-flights params shared by both trip directions."""
|
|
112
|
+
return dict(
|
|
113
|
+
seat=q.get("seat", "economy"),
|
|
100
114
|
passengers=Passengers(
|
|
101
115
|
adults=int(q.get("adults", 1)),
|
|
102
116
|
children=int(q.get("children", 0)),
|
|
@@ -113,17 +127,29 @@ def run(q: dict) -> dict:
|
|
|
113
127
|
exclude_basic_economy=bool(q.get("exclude_basic_economy", False)),
|
|
114
128
|
)
|
|
115
129
|
|
|
130
|
+
|
|
131
|
+
def _search_direction(date: str, src: str, dst: str, q: dict) -> list:
|
|
132
|
+
"""Run a single one-way search src->dst on date and return the flight list.
|
|
133
|
+
|
|
134
|
+
One-way fares. Used for trip="one-way", and (for round-trips) as a
|
|
135
|
+
reference search for the RETURN direction's leg schedules — the native RT
|
|
136
|
+
query does not include return legs.
|
|
137
|
+
"""
|
|
138
|
+
query = create_query(
|
|
139
|
+
flights=[FlightQuery(date=date, from_airport=src, to_airport=dst)],
|
|
140
|
+
trip="one-way",
|
|
141
|
+
**_query_params(q),
|
|
142
|
+
)
|
|
116
143
|
try:
|
|
117
144
|
result = get_flights(query)
|
|
118
145
|
except FlightsNotFound:
|
|
119
|
-
return
|
|
120
|
-
|
|
146
|
+
return []
|
|
121
147
|
currency = q.get("currency", "") or "default"
|
|
122
|
-
|
|
148
|
+
return [
|
|
123
149
|
{
|
|
124
150
|
"price": f.price,
|
|
125
151
|
"currency": currency,
|
|
126
|
-
"
|
|
152
|
+
"price_type": "one-way",
|
|
127
153
|
"airlines": f.airlines,
|
|
128
154
|
"type": f.type,
|
|
129
155
|
"carbon": {
|
|
@@ -135,7 +161,113 @@ def run(q: dict) -> dict:
|
|
|
135
161
|
}
|
|
136
162
|
for f in result
|
|
137
163
|
]
|
|
138
|
-
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _search_round_trip(out_date: str, ret_date: str, src: str, dst: str, q: dict) -> list:
|
|
167
|
+
"""Native fast-flights round-trip query (both legs in ONE query).
|
|
168
|
+
|
|
169
|
+
Returns flights whose `price` is the TRUE two-way round-trip total fare.
|
|
170
|
+
`legs` contain only the OUTBOUND direction (fast-flights limitation,
|
|
171
|
+
verified) — return-leg schedules must come from a separate one-way search.
|
|
172
|
+
"""
|
|
173
|
+
query = create_query(
|
|
174
|
+
flights=[
|
|
175
|
+
FlightQuery(date=out_date, from_airport=src, to_airport=dst),
|
|
176
|
+
FlightQuery(date=ret_date, from_airport=dst, to_airport=src),
|
|
177
|
+
],
|
|
178
|
+
trip="round-trip",
|
|
179
|
+
**_query_params(q),
|
|
180
|
+
)
|
|
181
|
+
try:
|
|
182
|
+
result = get_flights(query)
|
|
183
|
+
except FlightsNotFound:
|
|
184
|
+
return []
|
|
185
|
+
currency = q.get("currency", "") or "default"
|
|
186
|
+
return [
|
|
187
|
+
{
|
|
188
|
+
"price": f.price, # true two-way round-trip total
|
|
189
|
+
"price_type": "round-trip-total",
|
|
190
|
+
"currency": currency,
|
|
191
|
+
"airlines": f.airlines,
|
|
192
|
+
"type": f.type,
|
|
193
|
+
"carbon": {
|
|
194
|
+
"typical_on_route": f.carbon.typical_on_route,
|
|
195
|
+
"emission": f.carbon.emission,
|
|
196
|
+
},
|
|
197
|
+
"stops": len(f.flights) - 1,
|
|
198
|
+
"legs": [_leg(x) for x in f.flights], # outbound legs only
|
|
199
|
+
}
|
|
200
|
+
for f in result
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _route(src: str, dst: str) -> dict:
|
|
205
|
+
"""Resolved endpoint metadata + a ready-to-read summary."""
|
|
206
|
+
route = {
|
|
207
|
+
"from": airport_lookup.resolve(src),
|
|
208
|
+
"to": airport_lookup.resolve(dst),
|
|
209
|
+
}
|
|
210
|
+
route["summary"] = (
|
|
211
|
+
f"{route['from']['code']} {route['from']['city']}, "
|
|
212
|
+
f"{route['from']['country_name'] or route['from']['country']} -> "
|
|
213
|
+
f"{route['to']['code']} {route['to']['city']}, "
|
|
214
|
+
f"{route['to']['country_name'] or route['to']['country']}"
|
|
215
|
+
)
|
|
216
|
+
return route
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def run(q: dict) -> dict:
|
|
220
|
+
seat = q.get("seat", "economy")
|
|
221
|
+
trip = q.get("trip", "one-way")
|
|
222
|
+
if seat not in SEATS:
|
|
223
|
+
raise ValueError(f"seat must be one of {SEATS}")
|
|
224
|
+
if trip not in TRIPS:
|
|
225
|
+
raise ValueError(f"trip must be one of {TRIPS}")
|
|
226
|
+
|
|
227
|
+
outbound_date = q["outbound_date"]
|
|
228
|
+
src = q["from_airport"].strip().upper()
|
|
229
|
+
dst = q["to_airport"].strip().upper()
|
|
230
|
+
for code in (src, dst):
|
|
231
|
+
if not (code.isalpha() and len(code) == 3):
|
|
232
|
+
raise ValueError(f"airport code must be 3 letters, got {code!r}")
|
|
233
|
+
if not airport_lookup.is_known(code):
|
|
234
|
+
raise ValueError(
|
|
235
|
+
f"unknown airport code {code!r}: not in the airport database "
|
|
236
|
+
f"(resolve it to a valid IATA code first)"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
currency = q.get("currency", "") or "default"
|
|
240
|
+
|
|
241
|
+
if trip == "round-trip":
|
|
242
|
+
return_date = q.get("return_date")
|
|
243
|
+
if not return_date:
|
|
244
|
+
raise ValueError("return_date is required for round-trip")
|
|
245
|
+
# Native RT query for the TRUE two-way price (+outbound legs), plus a
|
|
246
|
+
# one-way reference search so return-leg schedules are still visible.
|
|
247
|
+
payload = {
|
|
248
|
+
"currency": currency,
|
|
249
|
+
"trip": "round-trip",
|
|
250
|
+
"outbound_route": _route(src, dst),
|
|
251
|
+
"return_route": _route(dst, src),
|
|
252
|
+
"round_trip": _search_round_trip(outbound_date, return_date, src, dst, q),
|
|
253
|
+
"return_options": _search_direction(return_date, dst, src, q),
|
|
254
|
+
"note": (
|
|
255
|
+
"round_trip[].price is the true two-way round-trip total from a "
|
|
256
|
+
"native fast-flights RT query; its legs cover the outbound "
|
|
257
|
+
"direction only. return_options[] are one-way fares for the "
|
|
258
|
+
"return direction (schedule reference, NOT round-trip prices). "
|
|
259
|
+
"Do not add one-way fares to estimate round-trip prices."
|
|
260
|
+
),
|
|
261
|
+
}
|
|
262
|
+
return payload
|
|
263
|
+
|
|
264
|
+
# one-way: keep the flat, backward-compatible shape.
|
|
265
|
+
return {
|
|
266
|
+
"currency": currency,
|
|
267
|
+
"trip": "one-way",
|
|
268
|
+
"route": _route(src, dst),
|
|
269
|
+
"flights": _search_direction(outbound_date, src, dst, q),
|
|
270
|
+
}
|
|
139
271
|
|
|
140
272
|
|
|
141
273
|
def main() -> int:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "find-flight",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Google Flights search for agents — a pi extension (search_flights tool) + portable skill, powered by the fast-flights scraper.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -25,12 +25,15 @@
|
|
|
25
25
|
"extensions/",
|
|
26
26
|
"skills/",
|
|
27
27
|
"flight_runner.py",
|
|
28
|
+
"airports.py",
|
|
29
|
+
"airports.json",
|
|
30
|
+
"countries.json",
|
|
28
31
|
"examples/",
|
|
29
32
|
"README.md",
|
|
30
33
|
"LICENSE"
|
|
31
34
|
],
|
|
32
35
|
"scripts": {
|
|
33
|
-
"verify": "python3 -c \"import json,sys; from pathlib import Path; Path('flight_runner.py')\" && python3 -m py_compile flight_runner.py && echo \"runner OK\"",
|
|
36
|
+
"verify": "python3 -c \"import json,sys; from pathlib import Path; Path('flight_runner.py')\" && python3 -m py_compile flight_runner.py airports.py && python3 -c \"import airports; assert airports.is_known('IST') and airports.country_name('TR')=='Turkey'\" && echo \"runner OK\"",
|
|
34
37
|
"prepublishOnly": "npm run verify"
|
|
35
38
|
},
|
|
36
39
|
"publishConfig": {
|
|
@@ -35,7 +35,11 @@ pip install fast-flights
|
|
|
35
35
|
```
|
|
36
36
|
Then resolve relative/ambiguous dates ("next Friday", "11-13 September") against this real year. Format every date as `YYYY-MM-DD`.
|
|
37
37
|
2. **Confirm one-way vs round-trip.** If the user didn't explicitly say, ASK: one-way (1 leg, needs only `date`+`from`+`to`) vs round-trip (2+ legs, needs a `return_date` too). Do not assume. If a stay duration or "until" date is given, that implies round-trip — but still confirm the dates.
|
|
38
|
-
3. **Verify the airport codes — never guess what a 3-letter IATA code means.** Models routinely map codes to the wrong city (e.g. misreading `ECN` as Edinburgh when it is really Ercan, or inventing a code). Before searching, confirm what `from_airport`/`to_airport` actually are: look the code up in the upstream airport list (`enums/airports.csv` in `AWeirdDev/flights`, ~9.7k rows, by code + name) or against a known route. If you can't verify a code, ask the user instead of assuming.
|
|
38
|
+
3. **Verify the airport codes — never guess what a 3-letter IATA code means.** Models routinely map codes to the wrong city (e.g. misreading `ECN` as Edinburgh when it is really Ercan, or inventing a code). Before searching, confirm what `from_airport`/`to_airport` actually are: look the code up in the upstream airport list (`enums/airports.csv` in `AWeirdDev/flights`, ~9.7k rows, by code + name + country) or against a known route. If you can't verify a code, ask the user instead of assuming.
|
|
39
|
+
|
|
40
|
+
**Country & airport awareness is built into the tools in this package.** The `search_flights` tool and `flight_runner.py` bundle a code → (airport name, city, state, ISO-3166 country) lookup (`airports.py` + `airports.json`/`countries.json`). Every search resolves both endpoint codes to their city and country **before** calling Google Flights, rejects an unknown code with a clear error, and returns per-leg `*_city`, `*_state`, `*_country` and `*_country_name` fields plus a human-readable `route.summary` (e.g. `IST Istanbul, Turkey -> ECN Nicosia, Cyprus`). So the agent never has to guess a code's meaning — the tool surfaces it.
|
|
41
|
+
|
|
42
|
+
Still treat output resolution as a *cross-check*, not a replacement for intent: an ambiguous request (e.g. user says "St. John's" but a code resolves elsewhere) should still be confirmed against the user's intent.
|
|
39
43
|
|
|
40
44
|
## Core Recipe — structured query
|
|
41
45
|
|
|
@@ -101,6 +105,8 @@ query = create_query(
|
|
|
101
105
|
)
|
|
102
106
|
```
|
|
103
107
|
|
|
108
|
+
`flight.price` on the result is the **true two-way total fare**; `flight.flights` contains only the **outbound** legs (no return segments are in the data).
|
|
109
|
+
|
|
104
110
|
## Natural-language path (agent-friendly)
|
|
105
111
|
|
|
106
112
|
`get_flights` also accepts a plain sentence instead of a `Query`:
|
|
@@ -135,9 +141,13 @@ for flight in result:
|
|
|
135
141
|
leg.plane_type # str
|
|
136
142
|
```
|
|
137
143
|
|
|
144
|
+
**Country & airport codes (the bundled tools add these).** When you call `search_flights` or `flight_runner.py` (not raw fast-flights), each leg is enriched with the resolved airport metadata from the bundled lookup — `from_city`, `from_state`, `from_country` (ISO-3166 alpha-2), `from_country_name`, and the matching `to_*` fields — plus `from_name`/`to_name`. A top-level `route` object gives both endpoints' resolved metadata and a ready-to-read `route.summary` like `IST Istanbul, Turkey -> ECN Nicosia, Cyprus`. This is how the agent knows the country and airport code with certainty instead of guessing.
|
|
145
|
+
|
|
138
146
|
**Currency is NOT attached to prices.** `flight.price` is a bare `int` with no currency symbol or code. The currency that the price is quoted in is whatever you passed to `create_query(currency=...)` (or Google's default if you passed `""` — often the local currency, e.g. Turkish Lira for a Turkey-originating route). When presenting prices, surface the currency explicitly: track the `currency` you passed (or the user's expected currency) and label every price with it. Never hardcode a currency symbol in your output format — it may not match the price's actual currency.
|
|
139
147
|
|
|
140
|
-
**Round-trip
|
|
148
|
+
**Round-trip legs — raw `fast-flights` gives only OUTBOUND (verified).** A single `create_query(..., trip="round-trip")` returns `flight.flights` with **only the outbound legs** — no return-leg detail — and `flight.price` as the **two-way total fare**. So on raw fast-flights, count stops/display legs as the outbound itinerary; do NOT invent return segments that aren't in the data.
|
|
149
|
+
|
|
150
|
+
**Round-trip legs — the bundled tools price the true RT fare.** `search_flights` / `flight_runner.py` run the **native round-trip query** (both legs in one `create_query`) as the price source: `round_trip[].price` is the **true two-way total** and its `legs` cover the **outbound direction only**. They additionally run **one one-way search for the return direction** (`return_options[]`) so real return-leg schedules are visible; those prices are **one-way reference fares, NOT round-trip prices** (a `price_type` field marks each entry: `round-trip-total` vs `one-way`). Each direction gets its own `outbound_route` / `return_route` metadata. No pairing is manufactured between a specific outbound and a specific return, because the API exposes no booking token linking them.
|
|
141
151
|
|
|
142
152
|
`FlightsNotFound` is raised when nothing matches (or Google returns an error status).
|
|
143
153
|
|
|
@@ -193,13 +203,14 @@ best = result[0] # or result[:3] for top-N
|
|
|
193
203
|
| Expecting `multi-city` to work | Unimplemented — treat as not supported today |
|
|
194
204
|
| Passing `airlines` to multiple legs expecting per-leg filtering | Only first leg's `airlines` is applied |
|
|
195
205
|
| Assuming 24h timestamps | Times are `(hour, minute)` on a 0–23 clock |
|
|
196
|
-
| Guessing what a 3-letter airport code means | Verify it against the upstream `enums/airports.csv` (or a known route) before searching |
|
|
206
|
+
| Guessing what a 3-letter airport code means | Verify it against the upstream `enums/airports.csv` (or a known route) before searching; the bundled `search_flights` tool resolves codes to city + country for you |
|
|
197
207
|
| Hardcoding a currency symbol in output | `price` has no currency — track/pass `currency` and label prices with it |
|
|
198
|
-
|
|
|
208
|
+
| Summing two one-way fares to quote a round-trip price | One-way fares (esp. business) are often far higher than half the RT fare (verified: IST↔BRU direct business 2026-11-20/22 = USD 855 RT vs ~USD 1,422 summed one-ways). Always use the native round-trip query / `round_trip[].price` |
|
|
209
|
+
| Assuming return legs are in raw round-trip data | Raw `fast-flights` returns only outbound legs (but with the true two-way price); the bundled `search_flights` tool adds a one-way return-direction search (`return_options[]`) so return-leg schedules ARE shown there |
|
|
199
210
|
|
|
200
211
|
## Real-world notes
|
|
201
212
|
|
|
202
213
|
- Version: `fast-flights` 3.x. See upstream repo `AWeirdDev/flights` and docs `aweirddev.github.io/flights`.
|
|
203
214
|
- Run a ready-made CLI: `python examples/search_flights.py --from TPE --to MYJ --date 2025-01-01 --seat economy`.
|
|
204
|
-
- **Round-trip
|
|
215
|
+
- **Round-trip pricing (verified 2026-09-11):** a single native `create_query(trip="round-trip")` returns the **true two-way total** in `flight.price` with **outbound legs only**. The bundled `search_flights` / `flight_runner.py` use that native query as the price source (`round_trip[]`) plus a one-way return-direction search for leg schedules (`return_options[]`, one-way reference fares). Never estimate RT prices by summing one-ways.
|
|
205
216
|
- **pi extension:** if the `search_flights` custom tool is active (via `extensions/flight-search.ts`), prefer calling it over writing ad-hoc Python — it returns structured, correctly-labelled results and leaves no scratch files. `flight_runner.py` is the equivalent standalone executor.
|