@tks/wayfinder 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 +38 -8
- package/package.json +11 -2
- package/src/cli.ts +59 -24
- package/src/format.ts +71 -7
- package/src/parse.ts +221 -26
- package/src/serpapi.ts +168 -29
- package/src/types.ts +38 -3
package/README.md
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
|
-
# wayfinder
|
|
2
|
-
|
|
3
|
-
CLI for one way flight search with SerpApi Google Flights.
|
|
1
|
+
# 🛫 wayfinder — Travel search for your terminal and your AI agents.
|
|
4
2
|
|
|
5
3
|
## Install
|
|
6
4
|
|
|
5
|
+
Requires [Bun](https://bun.sh/) runtime.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun install -g @tks/wayfinder
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or install from source:
|
|
12
|
+
|
|
7
13
|
```bash
|
|
14
|
+
git clone https://github.com/tksohishi/wayfinder.git
|
|
15
|
+
cd wayfinder
|
|
8
16
|
bun install
|
|
9
17
|
bun link
|
|
10
18
|
```
|
|
11
19
|
|
|
12
|
-
`bun link` makes the `wayfinder` command available in your shell.
|
|
13
|
-
|
|
14
20
|
## Setup
|
|
15
21
|
|
|
16
22
|
Set API key by environment variable (preferred):
|
|
@@ -32,17 +38,41 @@ Or store it in `~/.config/wayfinder/config.json`:
|
|
|
32
38
|
Search one way flights:
|
|
33
39
|
|
|
34
40
|
```bash
|
|
35
|
-
wayfinder --from SFO --to JFK --date 2026-04-10
|
|
41
|
+
wayfinder flights --from SFO --to JFK --date 2026-04-10
|
|
36
42
|
```
|
|
37
43
|
|
|
38
44
|
Search with filters:
|
|
39
45
|
|
|
40
46
|
```bash
|
|
41
|
-
wayfinder --from LAX --to SEA --date 2026-04-10 --airline AS --max-stops 0 --max-price 250 --depart-after 06:00 --depart-before 12:00
|
|
47
|
+
wayfinder flights --from LAX --to SEA --date 2026-04-10 --airline AS --max-stops 0 --max-price 250 --depart-after 06:00 --depart-before 12:00
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Exclude basic economy fares:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
wayfinder flights --from SFO --to JFK --date 2026-04-10 --exclude-basic
|
|
42
54
|
```
|
|
43
55
|
|
|
44
56
|
Structured output for scripting:
|
|
45
57
|
|
|
46
58
|
```bash
|
|
47
|
-
wayfinder --from SFO --to JFK --date 2026-04-10 --json | jq '.results[] | {price,airline,stops}'
|
|
59
|
+
wayfinder flights --from SFO --to JFK --date 2026-04-10 --json | jq '.results[] | {price,airline,stops}'
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Search hotels:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
wayfinder hotels --where "New York, NY" --check-in 2026-04-10 --check-out 2026-04-12
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Search hotels with filters:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
wayfinder hotels --where "Tokyo" --check-in 2026-04-10 --check-out 2026-04-13 --adults 2 --max-price 300 --rating 4
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Structured hotel output for scripting:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
wayfinder hotels --where "Paris" --check-in 2026-04-10 --check-out 2026-04-12 --json | jq '.results[] | {name,nightlyPrice,rating}'
|
|
48
78
|
```
|
package/package.json
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tks/wayfinder",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Travel search for your terminal and your AI agents",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/tksohishi/wayfinder.git"
|
|
8
|
+
},
|
|
9
|
+
"bugs": {
|
|
10
|
+
"url": "https://github.com/tksohishi/wayfinder/issues"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/tksohishi/wayfinder#readme",
|
|
4
13
|
"license": "MIT",
|
|
5
14
|
"private": false,
|
|
6
15
|
"type": "module",
|
|
7
16
|
"bin": {
|
|
8
|
-
"wayfinder": "
|
|
17
|
+
"wayfinder": "bin/wayfinder"
|
|
9
18
|
},
|
|
10
19
|
"files": [
|
|
11
20
|
"bin",
|
package/src/cli.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { resolveApiKey } from "./config";
|
|
2
2
|
import { CliError } from "./errors";
|
|
3
|
-
import {
|
|
3
|
+
import { renderFlightTable, renderHotelTable } from "./format";
|
|
4
4
|
import { parseCliArgs } from "./parse";
|
|
5
|
-
import { searchFlights } from "./serpapi";
|
|
5
|
+
import { searchFlights, searchHotels } from "./serpapi";
|
|
6
6
|
import { ExitCode } from "./types";
|
|
7
7
|
|
|
8
8
|
interface Output {
|
|
@@ -17,23 +17,35 @@ interface RunOptions {
|
|
|
17
17
|
output?: Output;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
const HELP_TEXT = `wayfinder
|
|
20
|
+
const HELP_TEXT = `wayfinder v0.2.0 travel search
|
|
21
21
|
|
|
22
22
|
Usage:
|
|
23
|
-
wayfinder --from SFO --to JFK --date 2026-03-21 [filters]
|
|
23
|
+
wayfinder flights --from SFO --to JFK --date 2026-03-21 [filters]
|
|
24
24
|
wayfinder flights one-way --from SFO --to JFK --date 2026-03-21 [filters]
|
|
25
|
+
wayfinder hotels --where "New York, NY" --check-in 2026-03-21 --check-out 2026-03-23 [filters]
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
Flights required:
|
|
27
28
|
--from <IATA> Origin airport code
|
|
28
29
|
--to <IATA> Destination airport code
|
|
29
30
|
--date <YYYY-MM-DD> Departure date
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
Flights optional filters:
|
|
32
33
|
--airline <IATA> Airline code, example UA
|
|
33
34
|
--max-stops <0|1|2> Maximum number of stops
|
|
34
35
|
--max-price <USD> Max price in USD
|
|
35
36
|
--depart-after <HH:MM> Start of departure window
|
|
36
37
|
--depart-before <HH:MM> End of departure window
|
|
38
|
+
--exclude-basic Exclude basic economy fares
|
|
39
|
+
|
|
40
|
+
Hotels required:
|
|
41
|
+
--where <QUERY> Destination or hotel search query
|
|
42
|
+
--check-in <YYYY-MM-DD> Check-in date
|
|
43
|
+
--check-out <YYYY-MM-DD> Check-out date
|
|
44
|
+
|
|
45
|
+
Hotels optional filters:
|
|
46
|
+
--adults <N> Number of adults (default 2)
|
|
47
|
+
--max-price <USD> Max nightly rate in USD
|
|
48
|
+
--rating <3.5|4|4.5|5> Minimum guest rating
|
|
37
49
|
|
|
38
50
|
Output:
|
|
39
51
|
--json Print structured JSON output`;
|
|
@@ -56,25 +68,48 @@ export async function runWayfinder(
|
|
|
56
68
|
}
|
|
57
69
|
|
|
58
70
|
const apiKey = resolveApiKey(options.env ?? process.env, options.homeDir);
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
71
|
+
if (parsed.mode === "flights") {
|
|
72
|
+
const flights = await searchFlights(parsed.query, apiKey, options.fetchImpl ?? fetch);
|
|
73
|
+
|
|
74
|
+
if (flights.length === 0) {
|
|
75
|
+
throw new CliError("No flights found for the selected query", ExitCode.NoResults);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (parsed.outputJson) {
|
|
79
|
+
output.stdout(
|
|
80
|
+
JSON.stringify(
|
|
81
|
+
{
|
|
82
|
+
query: parsed.query,
|
|
83
|
+
results: flights,
|
|
84
|
+
},
|
|
85
|
+
null,
|
|
86
|
+
2,
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
} else {
|
|
90
|
+
output.stdout(renderFlightTable(flights));
|
|
91
|
+
}
|
|
76
92
|
} else {
|
|
77
|
-
|
|
93
|
+
const hotels = await searchHotels(parsed.query, apiKey, options.fetchImpl ?? fetch);
|
|
94
|
+
|
|
95
|
+
if (hotels.length === 0) {
|
|
96
|
+
throw new CliError("No hotels found for the selected query", ExitCode.NoResults);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (parsed.outputJson) {
|
|
100
|
+
output.stdout(
|
|
101
|
+
JSON.stringify(
|
|
102
|
+
{
|
|
103
|
+
query: parsed.query,
|
|
104
|
+
results: hotels,
|
|
105
|
+
},
|
|
106
|
+
null,
|
|
107
|
+
2,
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
} else {
|
|
111
|
+
output.stdout(renderHotelTable(hotels));
|
|
112
|
+
}
|
|
78
113
|
}
|
|
79
114
|
|
|
80
115
|
return ExitCode.Success;
|
package/src/format.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FlightOption } from "./types";
|
|
1
|
+
import { FlightOption, HotelOption } from "./types";
|
|
2
2
|
|
|
3
3
|
const currencyFormatter = new Intl.NumberFormat("en-US", {
|
|
4
4
|
style: "currency",
|
|
@@ -6,7 +6,7 @@ const currencyFormatter = new Intl.NumberFormat("en-US", {
|
|
|
6
6
|
maximumFractionDigits: 0,
|
|
7
7
|
});
|
|
8
8
|
|
|
9
|
-
export function
|
|
9
|
+
export function renderFlightTable(options: FlightOption[]): string {
|
|
10
10
|
const rows = options.map((option) => ({
|
|
11
11
|
price: currencyFormatter.format(option.price),
|
|
12
12
|
airline: option.airline,
|
|
@@ -74,11 +74,75 @@ export function renderTable(options: FlightOption[]): string {
|
|
|
74
74
|
return lines.join("\n");
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
function
|
|
78
|
-
rows
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
77
|
+
export function renderHotelTable(options: HotelOption[]): string {
|
|
78
|
+
const rows = options.map((option) => ({
|
|
79
|
+
nightly: currencyFormatter.format(option.nightlyPrice),
|
|
80
|
+
total: typeof option.totalPrice === "number" ? currencyFormatter.format(option.totalPrice) : "n/a",
|
|
81
|
+
name: option.name,
|
|
82
|
+
rating: typeof option.rating === "number" ? option.rating.toFixed(1) : "n/a",
|
|
83
|
+
reviews: typeof option.reviews === "number" ? String(option.reviews) : "n/a",
|
|
84
|
+
location: option.location,
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
const headers = {
|
|
88
|
+
nightly: "PRICE/NIGHT",
|
|
89
|
+
total: "TOTAL",
|
|
90
|
+
name: "NAME",
|
|
91
|
+
rating: "RATING",
|
|
92
|
+
reviews: "REVIEWS",
|
|
93
|
+
location: "LOCATION",
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const widths = {
|
|
97
|
+
nightly: maxWidth(rows, "nightly", headers.nightly),
|
|
98
|
+
total: maxWidth(rows, "total", headers.total),
|
|
99
|
+
name: maxWidth(rows, "name", headers.name),
|
|
100
|
+
rating: maxWidth(rows, "rating", headers.rating),
|
|
101
|
+
reviews: maxWidth(rows, "reviews", headers.reviews),
|
|
102
|
+
location: maxWidth(rows, "location", headers.location),
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const lines: string[] = [];
|
|
106
|
+
|
|
107
|
+
lines.push(
|
|
108
|
+
[
|
|
109
|
+
headers.nightly.padEnd(widths.nightly),
|
|
110
|
+
headers.total.padEnd(widths.total),
|
|
111
|
+
headers.name.padEnd(widths.name),
|
|
112
|
+
headers.rating.padEnd(widths.rating),
|
|
113
|
+
headers.reviews.padEnd(widths.reviews),
|
|
114
|
+
headers.location.padEnd(widths.location),
|
|
115
|
+
].join(" "),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
lines.push(
|
|
119
|
+
[
|
|
120
|
+
"-".repeat(widths.nightly),
|
|
121
|
+
"-".repeat(widths.total),
|
|
122
|
+
"-".repeat(widths.name),
|
|
123
|
+
"-".repeat(widths.rating),
|
|
124
|
+
"-".repeat(widths.reviews),
|
|
125
|
+
"-".repeat(widths.location),
|
|
126
|
+
].join(" "),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
for (const row of rows) {
|
|
130
|
+
lines.push(
|
|
131
|
+
[
|
|
132
|
+
row.nightly.padEnd(widths.nightly),
|
|
133
|
+
row.total.padEnd(widths.total),
|
|
134
|
+
row.name.padEnd(widths.name),
|
|
135
|
+
row.rating.padEnd(widths.rating),
|
|
136
|
+
row.reviews.padEnd(widths.reviews),
|
|
137
|
+
row.location.padEnd(widths.location),
|
|
138
|
+
].join(" "),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return lines.join("\n");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function maxWidth(rows: Array<Record<string, string>>, key: string, header: string): number {
|
|
82
146
|
return rows.reduce((width, row) => Math.max(width, row[key].length), header.length);
|
|
83
147
|
}
|
|
84
148
|
|
package/src/parse.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CliError } from "./errors";
|
|
2
|
-
import { ExitCode, ParsedArgs } from "./types";
|
|
2
|
+
import { ExitCode, FlightQuery, HotelQuery, ParsedArgs } from "./types";
|
|
3
3
|
|
|
4
|
-
interface
|
|
4
|
+
interface FlightRawOptions {
|
|
5
5
|
from?: string;
|
|
6
6
|
to?: string;
|
|
7
7
|
date?: string;
|
|
@@ -10,15 +10,46 @@ interface RawOptions {
|
|
|
10
10
|
maxPrice?: string;
|
|
11
11
|
departAfter?: string;
|
|
12
12
|
departBefore?: string;
|
|
13
|
+
excludeBasic: boolean;
|
|
13
14
|
outputJson: boolean;
|
|
14
15
|
help: boolean;
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
interface HotelRawOptions {
|
|
19
|
+
where?: string;
|
|
20
|
+
checkIn?: string;
|
|
21
|
+
checkOut?: string;
|
|
22
|
+
adults?: string;
|
|
23
|
+
maxPrice?: string;
|
|
24
|
+
rating?: string;
|
|
25
|
+
outputJson: boolean;
|
|
26
|
+
help: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type SearchMode = "flights" | "hotels";
|
|
30
|
+
|
|
17
31
|
const HELP_FLAGS = new Set(["-h", "--help"]);
|
|
18
32
|
|
|
19
33
|
export function parseCliArgs(argv: string[]): ParsedArgs {
|
|
20
|
-
|
|
21
|
-
|
|
34
|
+
if (argv.some((token) => HELP_FLAGS.has(token))) {
|
|
35
|
+
return {
|
|
36
|
+
help: true,
|
|
37
|
+
outputJson: argv.includes("--json"),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const { mode, args } = stripSubcommands(argv);
|
|
42
|
+
|
|
43
|
+
if (mode === "hotels") {
|
|
44
|
+
return parseHotelsArgs(args);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return parseFlightsArgs(args);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseFlightsArgs(args: string[]): ParsedArgs {
|
|
51
|
+
const raw: FlightRawOptions = {
|
|
52
|
+
excludeBasic: false,
|
|
22
53
|
outputJson: false,
|
|
23
54
|
help: false,
|
|
24
55
|
};
|
|
@@ -36,6 +67,11 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
|
|
|
36
67
|
continue;
|
|
37
68
|
}
|
|
38
69
|
|
|
70
|
+
if (token === "--exclude-basic") {
|
|
71
|
+
raw.excludeBasic = true;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
39
75
|
if (!token.startsWith("--")) {
|
|
40
76
|
throw new CliError(`Unexpected argument: ${token}`, ExitCode.InvalidInput);
|
|
41
77
|
}
|
|
@@ -84,6 +120,84 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
|
|
|
84
120
|
};
|
|
85
121
|
}
|
|
86
122
|
|
|
123
|
+
return {
|
|
124
|
+
help: false,
|
|
125
|
+
mode: "flights",
|
|
126
|
+
outputJson: raw.outputJson,
|
|
127
|
+
query: buildFlightQuery(raw),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parseHotelsArgs(args: string[]): ParsedArgs {
|
|
132
|
+
const raw: HotelRawOptions = {
|
|
133
|
+
outputJson: false,
|
|
134
|
+
help: false,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
138
|
+
const token = args[i];
|
|
139
|
+
|
|
140
|
+
if (HELP_FLAGS.has(token)) {
|
|
141
|
+
raw.help = true;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (token === "--json") {
|
|
146
|
+
raw.outputJson = true;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!token.startsWith("--")) {
|
|
151
|
+
throw new CliError(`Unexpected argument: ${token}`, ExitCode.InvalidInput);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const value = args[i + 1];
|
|
155
|
+
if (!value || value.startsWith("--")) {
|
|
156
|
+
throw new CliError(`Missing value for ${token}`, ExitCode.InvalidInput);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
switch (token) {
|
|
160
|
+
case "--where":
|
|
161
|
+
raw.where = value;
|
|
162
|
+
break;
|
|
163
|
+
case "--check-in":
|
|
164
|
+
raw.checkIn = value;
|
|
165
|
+
break;
|
|
166
|
+
case "--check-out":
|
|
167
|
+
raw.checkOut = value;
|
|
168
|
+
break;
|
|
169
|
+
case "--adults":
|
|
170
|
+
raw.adults = value;
|
|
171
|
+
break;
|
|
172
|
+
case "--max-price":
|
|
173
|
+
raw.maxPrice = value;
|
|
174
|
+
break;
|
|
175
|
+
case "--rating":
|
|
176
|
+
raw.rating = value;
|
|
177
|
+
break;
|
|
178
|
+
default:
|
|
179
|
+
throw new CliError(`Unknown flag: ${token}`, ExitCode.InvalidInput);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
i += 1;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (raw.help) {
|
|
186
|
+
return {
|
|
187
|
+
help: true,
|
|
188
|
+
outputJson: raw.outputJson,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
help: false,
|
|
194
|
+
mode: "hotels",
|
|
195
|
+
outputJson: raw.outputJson,
|
|
196
|
+
query: buildHotelQuery(raw),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function buildFlightQuery(raw: FlightRawOptions): FlightQuery {
|
|
87
201
|
if (!raw.from || !raw.to || !raw.date) {
|
|
88
202
|
throw new CliError("Missing required flags: --from, --to, --date", ExitCode.InvalidInput);
|
|
89
203
|
}
|
|
@@ -95,7 +209,7 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
|
|
|
95
209
|
throw new CliError("Origin and destination must be different airports", ExitCode.InvalidInput);
|
|
96
210
|
}
|
|
97
211
|
|
|
98
|
-
const departureDate = normalizeDate(raw.date);
|
|
212
|
+
const departureDate = normalizeDate(raw.date, "departure");
|
|
99
213
|
const airlineCode = raw.airline ? normalizeAirlineCode(raw.airline) : undefined;
|
|
100
214
|
const maxStops = raw.maxStops ? normalizeMaxStops(raw.maxStops) : undefined;
|
|
101
215
|
const maxPrice = raw.maxPrice ? normalizeMaxPrice(raw.maxPrice) : undefined;
|
|
@@ -126,32 +240,70 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
|
|
|
126
240
|
}
|
|
127
241
|
|
|
128
242
|
return {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
departureAfterMinutes,
|
|
139
|
-
departureBeforeMinutes,
|
|
140
|
-
},
|
|
243
|
+
origin,
|
|
244
|
+
destination,
|
|
245
|
+
departureDate,
|
|
246
|
+
airlineCode,
|
|
247
|
+
maxStops,
|
|
248
|
+
maxPrice,
|
|
249
|
+
departureAfterMinutes,
|
|
250
|
+
departureBeforeMinutes,
|
|
251
|
+
excludeBasic: raw.excludeBasic || undefined,
|
|
141
252
|
};
|
|
142
253
|
}
|
|
143
254
|
|
|
144
|
-
function
|
|
255
|
+
function buildHotelQuery(raw: HotelRawOptions): HotelQuery {
|
|
256
|
+
if (!raw.where || !raw.checkIn || !raw.checkOut) {
|
|
257
|
+
throw new CliError(
|
|
258
|
+
"Missing required flags: --where, --check-in, --check-out",
|
|
259
|
+
ExitCode.InvalidInput,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const location = normalizeLocation(raw.where);
|
|
264
|
+
const checkInDate = normalizeDate(raw.checkIn, "check-in");
|
|
265
|
+
const checkOutDate = normalizeDate(raw.checkOut, "check-out");
|
|
266
|
+
const adults = raw.adults ? normalizeAdults(raw.adults) : 2;
|
|
267
|
+
const maxPrice = raw.maxPrice ? normalizeMaxPrice(raw.maxPrice) : undefined;
|
|
268
|
+
const minRating = raw.rating ? normalizeMinRating(raw.rating) : undefined;
|
|
269
|
+
|
|
270
|
+
const checkIn = parseDateOnly(checkInDate);
|
|
271
|
+
const checkOut = parseDateOnly(checkOutDate);
|
|
272
|
+
|
|
273
|
+
if (checkOut <= checkIn) {
|
|
274
|
+
throw new CliError("Check-out date must be after check-in date", ExitCode.InvalidInput);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
location,
|
|
279
|
+
checkInDate,
|
|
280
|
+
checkOutDate,
|
|
281
|
+
adults,
|
|
282
|
+
maxPrice,
|
|
283
|
+
minRating,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function stripSubcommands(argv: string[]): { mode: SearchMode; args: string[] } {
|
|
145
288
|
const args = [...argv];
|
|
289
|
+
if (args[0] === "hotels") {
|
|
290
|
+
args.shift();
|
|
291
|
+
return { mode: "hotels", args };
|
|
292
|
+
}
|
|
146
293
|
|
|
147
294
|
if (args[0] === "flights") {
|
|
148
295
|
args.shift();
|
|
149
296
|
if (args[0] === "one-way") {
|
|
150
297
|
args.shift();
|
|
151
298
|
}
|
|
299
|
+
|
|
300
|
+
return { mode: "flights", args };
|
|
152
301
|
}
|
|
153
302
|
|
|
154
|
-
|
|
303
|
+
throw new CliError(
|
|
304
|
+
"Missing subcommand: use `flights` or `hotels`",
|
|
305
|
+
ExitCode.InvalidInput,
|
|
306
|
+
);
|
|
155
307
|
}
|
|
156
308
|
|
|
157
309
|
function normalizeAirport(value: string, fieldName: "origin" | "destination"): string {
|
|
@@ -176,27 +328,43 @@ function normalizeAirlineCode(value: string): string {
|
|
|
176
328
|
return upper;
|
|
177
329
|
}
|
|
178
330
|
|
|
179
|
-
function
|
|
331
|
+
function normalizeLocation(value: string): string {
|
|
332
|
+
const trimmed = value.trim();
|
|
333
|
+
if (trimmed.length === 0) {
|
|
334
|
+
throw new CliError("Location cannot be empty", ExitCode.InvalidInput);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return trimmed;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function normalizeDate(value: string, fieldName: "departure" | "check-in" | "check-out"): string {
|
|
180
341
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
181
342
|
if (!match) {
|
|
182
343
|
throw new CliError("Invalid date. Expected YYYY-MM-DD", ExitCode.InvalidInput);
|
|
183
344
|
}
|
|
184
345
|
|
|
346
|
+
const date = parseDateOnly(value);
|
|
185
347
|
const year = Number(match[1]);
|
|
186
348
|
const month = Number(match[2]);
|
|
187
349
|
const day = Number(match[3]);
|
|
188
350
|
|
|
189
|
-
const date = new Date(year, month - 1, day);
|
|
190
351
|
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
|
|
191
|
-
|
|
352
|
+
if (fieldName === "departure") {
|
|
353
|
+
throw new CliError("Invalid departure date", ExitCode.InvalidInput);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
throw new CliError(`Invalid ${fieldName} date`, ExitCode.InvalidInput);
|
|
192
357
|
}
|
|
193
358
|
|
|
194
|
-
const
|
|
195
|
-
today.
|
|
196
|
-
date.setHours(0, 0, 0, 0);
|
|
359
|
+
const now = new Date();
|
|
360
|
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
197
361
|
|
|
198
362
|
if (date < today) {
|
|
199
|
-
|
|
363
|
+
if (fieldName === "departure") {
|
|
364
|
+
throw new CliError("Departure date cannot be in the past", ExitCode.InvalidInput);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
throw new CliError(`${capitalize(fieldName)} date cannot be in the past`, ExitCode.InvalidInput);
|
|
200
368
|
}
|
|
201
369
|
|
|
202
370
|
return value;
|
|
@@ -218,6 +386,24 @@ function normalizeMaxPrice(value: string): number {
|
|
|
218
386
|
return numeric;
|
|
219
387
|
}
|
|
220
388
|
|
|
389
|
+
function normalizeAdults(value: string): number {
|
|
390
|
+
const numeric = Number.parseInt(value, 10);
|
|
391
|
+
if (!Number.isInteger(numeric) || numeric <= 0) {
|
|
392
|
+
throw new CliError("--adults must be a positive integer", ExitCode.InvalidInput);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return numeric;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function normalizeMinRating(value: string): 3.5 | 4 | 4.5 | 5 {
|
|
399
|
+
const numeric = Number.parseFloat(value);
|
|
400
|
+
if (numeric !== 3.5 && numeric !== 4 && numeric !== 4.5 && numeric !== 5) {
|
|
401
|
+
throw new CliError("--rating must be one of: 3.5, 4, 4.5, 5", ExitCode.InvalidInput);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return numeric;
|
|
405
|
+
}
|
|
406
|
+
|
|
221
407
|
function normalizeTime(value: string, flagName: string): number {
|
|
222
408
|
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value);
|
|
223
409
|
if (!match) {
|
|
@@ -226,3 +412,12 @@ function normalizeTime(value: string, flagName: string): number {
|
|
|
226
412
|
|
|
227
413
|
return Number(match[1]) * 60 + Number(match[2]);
|
|
228
414
|
}
|
|
415
|
+
|
|
416
|
+
function parseDateOnly(value: string): Date {
|
|
417
|
+
const [year, month, day] = value.split("-").map((part) => Number(part));
|
|
418
|
+
return new Date(year, month - 1, day);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function capitalize(value: string): string {
|
|
422
|
+
return value.slice(0, 1).toUpperCase() + value.slice(1);
|
|
423
|
+
}
|
package/src/serpapi.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CliError } from "./errors";
|
|
2
|
-
import { ExitCode, FlightOption, FlightQuery } from "./types";
|
|
2
|
+
import { ExitCode, FlightOption, FlightQuery, HotelOption, HotelQuery } from "./types";
|
|
3
3
|
|
|
4
4
|
interface SerpApiAirport {
|
|
5
5
|
time?: string;
|
|
@@ -18,42 +18,44 @@ interface SerpApiItinerary {
|
|
|
18
18
|
flights?: SerpApiSegment[];
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
interface
|
|
21
|
+
interface SerpApiFlightsResponse {
|
|
22
22
|
error?: string;
|
|
23
23
|
best_flights?: SerpApiItinerary[];
|
|
24
24
|
other_flights?: SerpApiItinerary[];
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
interface SerpApiPrice {
|
|
28
|
+
lowest?: number;
|
|
29
|
+
extracted_lowest?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface SerpApiHotelProperty {
|
|
33
|
+
name?: string;
|
|
34
|
+
rate_per_night?: SerpApiPrice;
|
|
35
|
+
total_rate?: SerpApiPrice;
|
|
36
|
+
overall_rating?: number;
|
|
37
|
+
reviews?: number;
|
|
38
|
+
description?: string;
|
|
39
|
+
type?: string;
|
|
40
|
+
link?: string;
|
|
41
|
+
serpapi_property_details_link?: string;
|
|
42
|
+
google_property_details_link?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface SerpApiHotelsResponse {
|
|
46
|
+
error?: string;
|
|
47
|
+
properties?: SerpApiHotelProperty[];
|
|
48
|
+
}
|
|
49
|
+
|
|
27
50
|
export async function searchFlights(
|
|
28
51
|
query: FlightQuery,
|
|
29
52
|
apiKey: string,
|
|
30
53
|
fetchImpl: typeof fetch = fetch,
|
|
31
54
|
): Promise<FlightOption[]> {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
response = await fetchImpl(requestUrl, {
|
|
37
|
-
signal: AbortSignal.timeout(15_000),
|
|
38
|
-
});
|
|
39
|
-
} catch (error) {
|
|
40
|
-
if (error instanceof Error && error.name === "TimeoutError") {
|
|
41
|
-
throw new CliError("SerpApi request timed out", ExitCode.ApiFailure);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
throw new CliError("Failed to reach SerpApi", ExitCode.ApiFailure);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
if (!response.ok) {
|
|
48
|
-
throw new CliError(`SerpApi request failed with status ${response.status}`, ExitCode.ApiFailure);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
let payload: SerpApiResponse;
|
|
52
|
-
try {
|
|
53
|
-
payload = (await response.json()) as SerpApiResponse;
|
|
54
|
-
} catch {
|
|
55
|
-
throw new CliError("SerpApi returned invalid JSON", ExitCode.ApiFailure);
|
|
56
|
-
}
|
|
55
|
+
const payload = await fetchSerpApiJson<SerpApiFlightsResponse>(
|
|
56
|
+
buildFlightRequestUrl(query, apiKey),
|
|
57
|
+
fetchImpl,
|
|
58
|
+
);
|
|
57
59
|
|
|
58
60
|
if (typeof payload.error === "string" && payload.error.trim() !== "") {
|
|
59
61
|
throw new CliError(`SerpApi error: ${payload.error}`, ExitCode.ApiFailure);
|
|
@@ -76,7 +78,26 @@ export async function searchFlights(
|
|
|
76
78
|
return flights;
|
|
77
79
|
}
|
|
78
80
|
|
|
79
|
-
export function
|
|
81
|
+
export async function searchHotels(
|
|
82
|
+
query: HotelQuery,
|
|
83
|
+
apiKey: string,
|
|
84
|
+
fetchImpl: typeof fetch = fetch,
|
|
85
|
+
): Promise<HotelOption[]> {
|
|
86
|
+
const payload = await fetchSerpApiJson<SerpApiHotelsResponse>(
|
|
87
|
+
buildHotelRequestUrl(query, apiKey),
|
|
88
|
+
fetchImpl,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
if (typeof payload.error === "string" && payload.error.trim() !== "") {
|
|
92
|
+
throw new CliError(`SerpApi error: ${payload.error}`, ExitCode.ApiFailure);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const hotels = shapeHotelSerpApiResponse(payload);
|
|
96
|
+
hotels.sort((a, b) => a.nightlyPrice - b.nightlyPrice);
|
|
97
|
+
return hotels;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function shapeSerpApiResponse(payload: SerpApiFlightsResponse): FlightOption[] {
|
|
80
101
|
const merged = [...(payload.best_flights ?? []), ...(payload.other_flights ?? [])];
|
|
81
102
|
|
|
82
103
|
return merged
|
|
@@ -84,6 +105,12 @@ export function shapeSerpApiResponse(payload: SerpApiResponse): FlightOption[] {
|
|
|
84
105
|
.filter((option): option is FlightOption => option !== null);
|
|
85
106
|
}
|
|
86
107
|
|
|
108
|
+
export function shapeHotelSerpApiResponse(payload: SerpApiHotelsResponse): HotelOption[] {
|
|
109
|
+
return (payload.properties ?? [])
|
|
110
|
+
.map((property) => shapeHotelProperty(property))
|
|
111
|
+
.filter((option): option is HotelOption => option !== null);
|
|
112
|
+
}
|
|
113
|
+
|
|
87
114
|
export function filterByDepartureWindow(
|
|
88
115
|
options: FlightOption[],
|
|
89
116
|
minMinutes: number,
|
|
@@ -131,6 +158,34 @@ function shapeItinerary(itinerary: SerpApiItinerary): FlightOption | null {
|
|
|
131
158
|
};
|
|
132
159
|
}
|
|
133
160
|
|
|
161
|
+
function shapeHotelProperty(property: SerpApiHotelProperty): HotelOption | null {
|
|
162
|
+
if (typeof property.name !== "string" || property.name.trim() === "") {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const nightlyPrice = extractLowestPrice(property.rate_per_night);
|
|
167
|
+
if (typeof nightlyPrice !== "number") {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const totalPrice = extractLowestPrice(property.total_rate);
|
|
172
|
+
const rating = Number.isFinite(property.overall_rating) ? property.overall_rating : undefined;
|
|
173
|
+
const reviews = Number.isFinite(property.reviews) ? property.reviews : undefined;
|
|
174
|
+
const location = property.description?.trim() || property.type?.trim() || "n/a";
|
|
175
|
+
const link =
|
|
176
|
+
property.link || property.serpapi_property_details_link || property.google_property_details_link;
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
name: property.name.trim(),
|
|
180
|
+
nightlyPrice,
|
|
181
|
+
totalPrice,
|
|
182
|
+
rating,
|
|
183
|
+
reviews,
|
|
184
|
+
location,
|
|
185
|
+
link,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
134
189
|
function inferDurationMinutes(itinerary: SerpApiItinerary, segments: SerpApiSegment[]): number {
|
|
135
190
|
if (Number.isFinite(itinerary.total_duration)) {
|
|
136
191
|
return itinerary.total_duration as number;
|
|
@@ -146,7 +201,7 @@ function inferDurationMinutes(itinerary: SerpApiItinerary, segments: SerpApiSegm
|
|
|
146
201
|
return segmentDuration;
|
|
147
202
|
}
|
|
148
203
|
|
|
149
|
-
function
|
|
204
|
+
function buildFlightRequestUrl(query: FlightQuery, apiKey: string): string {
|
|
150
205
|
const url = new URL("https://serpapi.com/search.json");
|
|
151
206
|
|
|
152
207
|
url.searchParams.set("engine", "google_flights");
|
|
@@ -183,6 +238,34 @@ function buildRequestUrl(query: FlightQuery, apiKey: string): string {
|
|
|
183
238
|
url.searchParams.set("outbound_times", `${minHour},${maxHour}`);
|
|
184
239
|
}
|
|
185
240
|
|
|
241
|
+
if (query.excludeBasic) {
|
|
242
|
+
url.searchParams.set("exclude_basic", "true");
|
|
243
|
+
url.searchParams.set("travel_class", "1");
|
|
244
|
+
url.searchParams.set("gl", "us");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return url.toString();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function buildHotelRequestUrl(query: HotelQuery, apiKey: string): string {
|
|
251
|
+
const url = new URL("https://serpapi.com/search.json");
|
|
252
|
+
|
|
253
|
+
url.searchParams.set("engine", "google_hotels");
|
|
254
|
+
url.searchParams.set("q", query.location);
|
|
255
|
+
url.searchParams.set("check_in_date", query.checkInDate);
|
|
256
|
+
url.searchParams.set("check_out_date", query.checkOutDate);
|
|
257
|
+
url.searchParams.set("adults", String(query.adults));
|
|
258
|
+
url.searchParams.set("currency", "USD");
|
|
259
|
+
url.searchParams.set("api_key", apiKey);
|
|
260
|
+
|
|
261
|
+
if (typeof query.maxPrice === "number") {
|
|
262
|
+
url.searchParams.set("max_price", String(query.maxPrice));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (typeof query.minRating === "number") {
|
|
266
|
+
url.searchParams.set("rating", toSerpApiRating(query.minRating));
|
|
267
|
+
}
|
|
268
|
+
|
|
186
269
|
return url.toString();
|
|
187
270
|
}
|
|
188
271
|
|
|
@@ -198,6 +281,62 @@ function toSerpApiStopsFilter(maxStops: number): string {
|
|
|
198
281
|
return "3";
|
|
199
282
|
}
|
|
200
283
|
|
|
284
|
+
function toSerpApiRating(rating: 3.5 | 4 | 4.5 | 5): string {
|
|
285
|
+
if (rating === 3.5) {
|
|
286
|
+
return "7";
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (rating === 4) {
|
|
290
|
+
return "8";
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (rating === 4.5) {
|
|
294
|
+
return "9";
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return "10";
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function fetchSerpApiJson<T>(
|
|
301
|
+
requestUrl: string,
|
|
302
|
+
fetchImpl: typeof fetch,
|
|
303
|
+
): Promise<T> {
|
|
304
|
+
let response: Response;
|
|
305
|
+
try {
|
|
306
|
+
response = await fetchImpl(requestUrl, {
|
|
307
|
+
signal: AbortSignal.timeout(15_000),
|
|
308
|
+
});
|
|
309
|
+
} catch (error) {
|
|
310
|
+
if (error instanceof Error && error.name === "TimeoutError") {
|
|
311
|
+
throw new CliError("SerpApi request timed out", ExitCode.ApiFailure);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
throw new CliError("Failed to reach SerpApi", ExitCode.ApiFailure);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (!response.ok) {
|
|
318
|
+
throw new CliError(`SerpApi request failed with status ${response.status}`, ExitCode.ApiFailure);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
return (await response.json()) as T;
|
|
323
|
+
} catch {
|
|
324
|
+
throw new CliError("SerpApi returned invalid JSON", ExitCode.ApiFailure);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function extractLowestPrice(value?: SerpApiPrice): number | undefined {
|
|
329
|
+
if (Number.isFinite(value?.lowest)) {
|
|
330
|
+
return value?.lowest;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (Number.isFinite(value?.extracted_lowest)) {
|
|
334
|
+
return value?.extracted_lowest;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
|
|
201
340
|
function extractMinutes(value: string): number | null {
|
|
202
341
|
const match = /\b([01]?\d|2[0-3]):([0-5]\d)\b/.exec(value);
|
|
203
342
|
if (!match) {
|
package/src/types.ts
CHANGED
|
@@ -18,14 +18,39 @@ export interface FlightQuery {
|
|
|
18
18
|
maxPrice?: number;
|
|
19
19
|
departureAfterMinutes?: number;
|
|
20
20
|
departureBeforeMinutes?: number;
|
|
21
|
+
excludeBasic?: boolean;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
export interface
|
|
24
|
-
|
|
24
|
+
export interface HotelQuery {
|
|
25
|
+
location: string;
|
|
26
|
+
checkInDate: string;
|
|
27
|
+
checkOutDate: string;
|
|
28
|
+
adults: number;
|
|
29
|
+
maxPrice?: number;
|
|
30
|
+
minRating?: 3.5 | 4 | 4.5 | 5;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ParsedArgsHelp {
|
|
34
|
+
help: true;
|
|
35
|
+
outputJson: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ParsedArgsFlights {
|
|
39
|
+
help: false;
|
|
40
|
+
mode: "flights";
|
|
25
41
|
outputJson: boolean;
|
|
26
|
-
|
|
42
|
+
query: FlightQuery;
|
|
27
43
|
}
|
|
28
44
|
|
|
45
|
+
export interface ParsedArgsHotels {
|
|
46
|
+
help: false;
|
|
47
|
+
mode: "hotels";
|
|
48
|
+
outputJson: boolean;
|
|
49
|
+
query: HotelQuery;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type ParsedArgs = ParsedArgsHelp | ParsedArgsFlights | ParsedArgsHotels;
|
|
53
|
+
|
|
29
54
|
export interface FlightOption {
|
|
30
55
|
price: number;
|
|
31
56
|
airline: string;
|
|
@@ -34,3 +59,13 @@ export interface FlightOption {
|
|
|
34
59
|
durationMinutes: number;
|
|
35
60
|
stops: number;
|
|
36
61
|
}
|
|
62
|
+
|
|
63
|
+
export interface HotelOption {
|
|
64
|
+
name: string;
|
|
65
|
+
nightlyPrice: number;
|
|
66
|
+
totalPrice?: number;
|
|
67
|
+
rating?: number;
|
|
68
|
+
reviews?: number;
|
|
69
|
+
location: string;
|
|
70
|
+
link?: string;
|
|
71
|
+
}
|