@tks/wayfinder 0.1.0 → 0.2.1
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 +44 -8
- package/package.json +11 -2
- package/src/cli.ts +116 -23
- package/src/format.ts +71 -7
- package/src/parse.ts +331 -26
- package/src/serpapi.ts +357 -33
- package/src/types.ts +74 -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
7
|
```bash
|
|
8
|
+
bun install -g @tks/wayfinder
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or install from source:
|
|
12
|
+
|
|
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,47 @@ 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
|
+
Get booking links from selected flight tokens:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
wayfinder flights booking --from LAS --to JFK --date 2026-05-29 --token "<TOKEN_1>" --token "<TOKEN_2>" --json
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Search hotels:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
wayfinder hotels --where "New York, NY" --check-in 2026-04-10 --check-out 2026-04-12
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Search hotels with filters:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
wayfinder hotels --where "Tokyo" --check-in 2026-04-10 --check-out 2026-04-13 --adults 2 --max-price 300 --rating 4
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Structured hotel output for scripting:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
wayfinder hotels --where "Paris" --check-in 2026-04-10 --check-out 2026-04-12 --json | jq '.results[] | {name,nightlyPrice,rating}'
|
|
48
84
|
```
|
package/package.json
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tks/wayfinder",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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 { searchFlightBookingOptions, searchFlights, searchHotels } from "./serpapi";
|
|
6
6
|
import { ExitCode } from "./types";
|
|
7
7
|
|
|
8
8
|
interface Output {
|
|
@@ -17,23 +17,43 @@ 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 flights booking --from SFO --to JFK --date 2026-03-21 --token <BOOKING_TOKEN> [--token <BOOKING_TOKEN>] [--json]
|
|
26
|
+
wayfinder hotels --where "New York, NY" --check-in 2026-03-21 --check-out 2026-03-23 [filters]
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
Flights required:
|
|
27
29
|
--from <IATA> Origin airport code
|
|
28
30
|
--to <IATA> Destination airport code
|
|
29
31
|
--date <YYYY-MM-DD> Departure date
|
|
30
32
|
|
|
31
|
-
|
|
33
|
+
Flights optional filters:
|
|
32
34
|
--airline <IATA> Airline code, example UA
|
|
33
35
|
--max-stops <0|1|2> Maximum number of stops
|
|
34
36
|
--max-price <USD> Max price in USD
|
|
35
37
|
--depart-after <HH:MM> Start of departure window
|
|
36
38
|
--depart-before <HH:MM> End of departure window
|
|
39
|
+
--exclude-basic Exclude basic economy fares
|
|
40
|
+
|
|
41
|
+
Flights booking required:
|
|
42
|
+
--from <IATA> Origin airport code
|
|
43
|
+
--to <IATA> Destination airport code
|
|
44
|
+
--date <YYYY-MM-DD> Departure date
|
|
45
|
+
--token <BOOKING_TOKEN> Booking token from a flights search result
|
|
46
|
+
(repeat --token to request multiple options)
|
|
47
|
+
|
|
48
|
+
Hotels required:
|
|
49
|
+
--where <QUERY> Destination or hotel search query
|
|
50
|
+
--check-in <YYYY-MM-DD> Check-in date
|
|
51
|
+
--check-out <YYYY-MM-DD> Check-out date
|
|
52
|
+
|
|
53
|
+
Hotels optional filters:
|
|
54
|
+
--adults <N> Number of adults (default 2)
|
|
55
|
+
--max-price <USD> Max nightly rate in USD
|
|
56
|
+
--rating <3.5|4|4.5|5> Minimum guest rating
|
|
37
57
|
|
|
38
58
|
Output:
|
|
39
59
|
--json Print structured JSON output`;
|
|
@@ -56,25 +76,84 @@ export async function runWayfinder(
|
|
|
56
76
|
}
|
|
57
77
|
|
|
58
78
|
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
|
-
|
|
79
|
+
if (parsed.mode === "flights") {
|
|
80
|
+
const flights = await searchFlights(parsed.query, apiKey, options.fetchImpl ?? fetch);
|
|
81
|
+
|
|
82
|
+
if (flights.options.length === 0) {
|
|
83
|
+
throw new CliError("No flights found for the selected query", ExitCode.NoResults);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (parsed.outputJson) {
|
|
87
|
+
output.stdout(
|
|
88
|
+
JSON.stringify(
|
|
89
|
+
{
|
|
90
|
+
query: parsed.query,
|
|
91
|
+
googleFlightsUrl: flights.googleFlightsUrl,
|
|
92
|
+
results: flights.options,
|
|
93
|
+
},
|
|
94
|
+
null,
|
|
95
|
+
2,
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
} else {
|
|
99
|
+
output.stdout(renderFlightTable(flights.options));
|
|
100
|
+
}
|
|
101
|
+
} else if (parsed.mode === "flight-booking") {
|
|
102
|
+
const bookingResults = await searchFlightBookingOptions(
|
|
103
|
+
parsed.query,
|
|
104
|
+
apiKey,
|
|
105
|
+
options.fetchImpl ?? fetch,
|
|
75
106
|
);
|
|
107
|
+
|
|
108
|
+
const flightLinks = bookingResults
|
|
109
|
+
.filter((result) => typeof result.googleFlightsUrl === "string")
|
|
110
|
+
.map((result) => ({
|
|
111
|
+
token: result.token,
|
|
112
|
+
googleFlightsUrl: result.googleFlightsUrl as string,
|
|
113
|
+
}));
|
|
114
|
+
|
|
115
|
+
if (flightLinks.length === 0) {
|
|
116
|
+
throw new CliError(
|
|
117
|
+
"No Google Flights links found for the provided token(s)",
|
|
118
|
+
ExitCode.NoResults,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (parsed.outputJson) {
|
|
123
|
+
output.stdout(
|
|
124
|
+
JSON.stringify(
|
|
125
|
+
{
|
|
126
|
+
query: parsed.query,
|
|
127
|
+
results: flightLinks,
|
|
128
|
+
},
|
|
129
|
+
null,
|
|
130
|
+
2,
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
} else {
|
|
134
|
+
output.stdout(renderFlightBookingText(flightLinks));
|
|
135
|
+
}
|
|
76
136
|
} else {
|
|
77
|
-
|
|
137
|
+
const hotels = await searchHotels(parsed.query, apiKey, options.fetchImpl ?? fetch);
|
|
138
|
+
|
|
139
|
+
if (hotels.length === 0) {
|
|
140
|
+
throw new CliError("No hotels found for the selected query", ExitCode.NoResults);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (parsed.outputJson) {
|
|
144
|
+
output.stdout(
|
|
145
|
+
JSON.stringify(
|
|
146
|
+
{
|
|
147
|
+
query: parsed.query,
|
|
148
|
+
results: hotels,
|
|
149
|
+
},
|
|
150
|
+
null,
|
|
151
|
+
2,
|
|
152
|
+
),
|
|
153
|
+
);
|
|
154
|
+
} else {
|
|
155
|
+
output.stdout(renderHotelTable(hotels));
|
|
156
|
+
}
|
|
78
157
|
}
|
|
79
158
|
|
|
80
159
|
return ExitCode.Success;
|
|
@@ -93,3 +172,17 @@ if (import.meta.main) {
|
|
|
93
172
|
const code = await runWayfinder(process.argv.slice(2));
|
|
94
173
|
process.exitCode = code;
|
|
95
174
|
}
|
|
175
|
+
|
|
176
|
+
function renderFlightBookingText(
|
|
177
|
+
results: Array<{ token: string; googleFlightsUrl: string }>,
|
|
178
|
+
): string {
|
|
179
|
+
const lines: string[] = [];
|
|
180
|
+
|
|
181
|
+
for (const result of results) {
|
|
182
|
+
lines.push(`TOKEN: ${result.token}`);
|
|
183
|
+
lines.push(`GOOGLE FLIGHTS: ${result.googleFlightsUrl}`);
|
|
184
|
+
lines.push("");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return lines.join("\n").trimEnd();
|
|
188
|
+
}
|
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, FlightBookingQuery, 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,59 @@ 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
|
+
interface FlightBookingRawOptions {
|
|
30
|
+
from?: string;
|
|
31
|
+
to?: string;
|
|
32
|
+
date?: string;
|
|
33
|
+
tokens: string[];
|
|
34
|
+
outputJson: boolean;
|
|
35
|
+
help: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type SearchMode = "flights" | "hotels" | "flight-booking";
|
|
39
|
+
|
|
17
40
|
const HELP_FLAGS = new Set(["-h", "--help"]);
|
|
18
41
|
|
|
19
42
|
export function parseCliArgs(argv: string[]): ParsedArgs {
|
|
20
|
-
|
|
21
|
-
|
|
43
|
+
if (argv.some((token) => HELP_FLAGS.has(token))) {
|
|
44
|
+
return {
|
|
45
|
+
help: true,
|
|
46
|
+
outputJson: argv.includes("--json"),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const { mode, args } = stripSubcommands(argv);
|
|
51
|
+
|
|
52
|
+
if (mode === "hotels") {
|
|
53
|
+
return parseHotelsArgs(args);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (mode === "flight-booking") {
|
|
57
|
+
return parseFlightBookingArgs(args);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return parseFlightsArgs(args);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseFlightsArgs(args: string[]): ParsedArgs {
|
|
64
|
+
const raw: FlightRawOptions = {
|
|
65
|
+
excludeBasic: false,
|
|
22
66
|
outputJson: false,
|
|
23
67
|
help: false,
|
|
24
68
|
};
|
|
@@ -36,6 +80,11 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
|
|
|
36
80
|
continue;
|
|
37
81
|
}
|
|
38
82
|
|
|
83
|
+
if (token === "--exclude-basic") {
|
|
84
|
+
raw.excludeBasic = true;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
39
88
|
if (!token.startsWith("--")) {
|
|
40
89
|
throw new CliError(`Unexpected argument: ${token}`, ExitCode.InvalidInput);
|
|
41
90
|
}
|
|
@@ -84,6 +133,151 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
|
|
|
84
133
|
};
|
|
85
134
|
}
|
|
86
135
|
|
|
136
|
+
return {
|
|
137
|
+
help: false,
|
|
138
|
+
mode: "flights",
|
|
139
|
+
outputJson: raw.outputJson,
|
|
140
|
+
query: buildFlightQuery(raw),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parseHotelsArgs(args: string[]): ParsedArgs {
|
|
145
|
+
const raw: HotelRawOptions = {
|
|
146
|
+
outputJson: false,
|
|
147
|
+
help: false,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
151
|
+
const token = args[i];
|
|
152
|
+
|
|
153
|
+
if (HELP_FLAGS.has(token)) {
|
|
154
|
+
raw.help = true;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (token === "--json") {
|
|
159
|
+
raw.outputJson = true;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!token.startsWith("--")) {
|
|
164
|
+
throw new CliError(`Unexpected argument: ${token}`, ExitCode.InvalidInput);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const value = args[i + 1];
|
|
168
|
+
if (!value || value.startsWith("--")) {
|
|
169
|
+
throw new CliError(`Missing value for ${token}`, ExitCode.InvalidInput);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
switch (token) {
|
|
173
|
+
case "--where":
|
|
174
|
+
raw.where = value;
|
|
175
|
+
break;
|
|
176
|
+
case "--check-in":
|
|
177
|
+
raw.checkIn = value;
|
|
178
|
+
break;
|
|
179
|
+
case "--check-out":
|
|
180
|
+
raw.checkOut = value;
|
|
181
|
+
break;
|
|
182
|
+
case "--adults":
|
|
183
|
+
raw.adults = value;
|
|
184
|
+
break;
|
|
185
|
+
case "--max-price":
|
|
186
|
+
raw.maxPrice = value;
|
|
187
|
+
break;
|
|
188
|
+
case "--rating":
|
|
189
|
+
raw.rating = value;
|
|
190
|
+
break;
|
|
191
|
+
default:
|
|
192
|
+
throw new CliError(`Unknown flag: ${token}`, ExitCode.InvalidInput);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
i += 1;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (raw.help) {
|
|
199
|
+
return {
|
|
200
|
+
help: true,
|
|
201
|
+
outputJson: raw.outputJson,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
help: false,
|
|
207
|
+
mode: "hotels",
|
|
208
|
+
outputJson: raw.outputJson,
|
|
209
|
+
query: buildHotelQuery(raw),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function parseFlightBookingArgs(args: string[]): ParsedArgs {
|
|
214
|
+
const raw: FlightBookingRawOptions = {
|
|
215
|
+
tokens: [],
|
|
216
|
+
outputJson: false,
|
|
217
|
+
help: false,
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
221
|
+
const token = args[i];
|
|
222
|
+
|
|
223
|
+
if (HELP_FLAGS.has(token)) {
|
|
224
|
+
raw.help = true;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (token === "--json") {
|
|
229
|
+
raw.outputJson = true;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (token === "--token") {
|
|
234
|
+
const value = args[i + 1];
|
|
235
|
+
if (!value || value.startsWith("--")) {
|
|
236
|
+
throw new CliError("Missing value for --token", ExitCode.InvalidInput);
|
|
237
|
+
}
|
|
238
|
+
raw.tokens.push(value.trim());
|
|
239
|
+
i += 1;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const value = args[i + 1];
|
|
244
|
+
if (!value || value.startsWith("--")) {
|
|
245
|
+
throw new CliError(`Missing value for ${token}`, ExitCode.InvalidInput);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
switch (token) {
|
|
249
|
+
case "--from":
|
|
250
|
+
raw.from = value;
|
|
251
|
+
break;
|
|
252
|
+
case "--to":
|
|
253
|
+
raw.to = value;
|
|
254
|
+
break;
|
|
255
|
+
case "--date":
|
|
256
|
+
raw.date = value;
|
|
257
|
+
break;
|
|
258
|
+
default:
|
|
259
|
+
throw new CliError(`Unknown flag: ${token}`, ExitCode.InvalidInput);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
i += 1;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (raw.help) {
|
|
266
|
+
return {
|
|
267
|
+
help: true,
|
|
268
|
+
outputJson: raw.outputJson,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
help: false,
|
|
274
|
+
mode: "flight-booking",
|
|
275
|
+
outputJson: raw.outputJson,
|
|
276
|
+
query: buildFlightBookingQuery(raw),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function buildFlightQuery(raw: FlightRawOptions): FlightQuery {
|
|
87
281
|
if (!raw.from || !raw.to || !raw.date) {
|
|
88
282
|
throw new CliError("Missing required flags: --from, --to, --date", ExitCode.InvalidInput);
|
|
89
283
|
}
|
|
@@ -95,7 +289,7 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
|
|
|
95
289
|
throw new CliError("Origin and destination must be different airports", ExitCode.InvalidInput);
|
|
96
290
|
}
|
|
97
291
|
|
|
98
|
-
const departureDate = normalizeDate(raw.date);
|
|
292
|
+
const departureDate = normalizeDate(raw.date, "departure");
|
|
99
293
|
const airlineCode = raw.airline ? normalizeAirlineCode(raw.airline) : undefined;
|
|
100
294
|
const maxStops = raw.maxStops ? normalizeMaxStops(raw.maxStops) : undefined;
|
|
101
295
|
const maxPrice = raw.maxPrice ? normalizeMaxPrice(raw.maxPrice) : undefined;
|
|
@@ -126,32 +320,100 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
|
|
|
126
320
|
}
|
|
127
321
|
|
|
128
322
|
return {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
departureAfterMinutes,
|
|
139
|
-
departureBeforeMinutes,
|
|
140
|
-
},
|
|
323
|
+
origin,
|
|
324
|
+
destination,
|
|
325
|
+
departureDate,
|
|
326
|
+
airlineCode,
|
|
327
|
+
maxStops,
|
|
328
|
+
maxPrice,
|
|
329
|
+
departureAfterMinutes,
|
|
330
|
+
departureBeforeMinutes,
|
|
331
|
+
excludeBasic: raw.excludeBasic || undefined,
|
|
141
332
|
};
|
|
142
333
|
}
|
|
143
334
|
|
|
144
|
-
function
|
|
335
|
+
function buildHotelQuery(raw: HotelRawOptions): HotelQuery {
|
|
336
|
+
if (!raw.where || !raw.checkIn || !raw.checkOut) {
|
|
337
|
+
throw new CliError(
|
|
338
|
+
"Missing required flags: --where, --check-in, --check-out",
|
|
339
|
+
ExitCode.InvalidInput,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const location = normalizeLocation(raw.where);
|
|
344
|
+
const checkInDate = normalizeDate(raw.checkIn, "check-in");
|
|
345
|
+
const checkOutDate = normalizeDate(raw.checkOut, "check-out");
|
|
346
|
+
const adults = raw.adults ? normalizeAdults(raw.adults) : 2;
|
|
347
|
+
const maxPrice = raw.maxPrice ? normalizeMaxPrice(raw.maxPrice) : undefined;
|
|
348
|
+
const minRating = raw.rating ? normalizeMinRating(raw.rating) : undefined;
|
|
349
|
+
|
|
350
|
+
const checkIn = parseDateOnly(checkInDate);
|
|
351
|
+
const checkOut = parseDateOnly(checkOutDate);
|
|
352
|
+
|
|
353
|
+
if (checkOut <= checkIn) {
|
|
354
|
+
throw new CliError("Check-out date must be after check-in date", ExitCode.InvalidInput);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
location,
|
|
359
|
+
checkInDate,
|
|
360
|
+
checkOutDate,
|
|
361
|
+
adults,
|
|
362
|
+
maxPrice,
|
|
363
|
+
minRating,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function buildFlightBookingQuery(raw: FlightBookingRawOptions): FlightBookingQuery {
|
|
368
|
+
if (!raw.from || !raw.to || !raw.date) {
|
|
369
|
+
throw new CliError("Missing required flags: --from, --to, --date", ExitCode.InvalidInput);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const origin = normalizeAirport(raw.from, "origin");
|
|
373
|
+
const destination = normalizeAirport(raw.to, "destination");
|
|
374
|
+
if (origin === destination) {
|
|
375
|
+
throw new CliError("Origin and destination must be different airports", ExitCode.InvalidInput);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const departureDate = normalizeDate(raw.date, "departure");
|
|
379
|
+
const tokens = raw.tokens.filter((token) => token.length > 0);
|
|
380
|
+
if (tokens.length === 0) {
|
|
381
|
+
throw new CliError("Missing required flag: --token", ExitCode.InvalidInput);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
origin,
|
|
386
|
+
destination,
|
|
387
|
+
departureDate,
|
|
388
|
+
tokens,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function stripSubcommands(argv: string[]): { mode: SearchMode; args: string[] } {
|
|
145
393
|
const args = [...argv];
|
|
394
|
+
if (args[0] === "hotels") {
|
|
395
|
+
args.shift();
|
|
396
|
+
return { mode: "hotels", args };
|
|
397
|
+
}
|
|
146
398
|
|
|
147
399
|
if (args[0] === "flights") {
|
|
148
400
|
args.shift();
|
|
401
|
+
if (args[0] === "booking") {
|
|
402
|
+
args.shift();
|
|
403
|
+
return { mode: "flight-booking", args };
|
|
404
|
+
}
|
|
405
|
+
|
|
149
406
|
if (args[0] === "one-way") {
|
|
150
407
|
args.shift();
|
|
151
408
|
}
|
|
409
|
+
|
|
410
|
+
return { mode: "flights", args };
|
|
152
411
|
}
|
|
153
412
|
|
|
154
|
-
|
|
413
|
+
throw new CliError(
|
|
414
|
+
"Missing subcommand: use `flights` or `hotels`",
|
|
415
|
+
ExitCode.InvalidInput,
|
|
416
|
+
);
|
|
155
417
|
}
|
|
156
418
|
|
|
157
419
|
function normalizeAirport(value: string, fieldName: "origin" | "destination"): string {
|
|
@@ -176,27 +438,43 @@ function normalizeAirlineCode(value: string): string {
|
|
|
176
438
|
return upper;
|
|
177
439
|
}
|
|
178
440
|
|
|
179
|
-
function
|
|
441
|
+
function normalizeLocation(value: string): string {
|
|
442
|
+
const trimmed = value.trim();
|
|
443
|
+
if (trimmed.length === 0) {
|
|
444
|
+
throw new CliError("Location cannot be empty", ExitCode.InvalidInput);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return trimmed;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function normalizeDate(value: string, fieldName: "departure" | "check-in" | "check-out"): string {
|
|
180
451
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
181
452
|
if (!match) {
|
|
182
453
|
throw new CliError("Invalid date. Expected YYYY-MM-DD", ExitCode.InvalidInput);
|
|
183
454
|
}
|
|
184
455
|
|
|
456
|
+
const date = parseDateOnly(value);
|
|
185
457
|
const year = Number(match[1]);
|
|
186
458
|
const month = Number(match[2]);
|
|
187
459
|
const day = Number(match[3]);
|
|
188
460
|
|
|
189
|
-
const date = new Date(year, month - 1, day);
|
|
190
461
|
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
|
|
191
|
-
|
|
462
|
+
if (fieldName === "departure") {
|
|
463
|
+
throw new CliError("Invalid departure date", ExitCode.InvalidInput);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
throw new CliError(`Invalid ${fieldName} date`, ExitCode.InvalidInput);
|
|
192
467
|
}
|
|
193
468
|
|
|
194
|
-
const
|
|
195
|
-
today.
|
|
196
|
-
date.setHours(0, 0, 0, 0);
|
|
469
|
+
const now = new Date();
|
|
470
|
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
197
471
|
|
|
198
472
|
if (date < today) {
|
|
199
|
-
|
|
473
|
+
if (fieldName === "departure") {
|
|
474
|
+
throw new CliError("Departure date cannot be in the past", ExitCode.InvalidInput);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
throw new CliError(`${capitalize(fieldName)} date cannot be in the past`, ExitCode.InvalidInput);
|
|
200
478
|
}
|
|
201
479
|
|
|
202
480
|
return value;
|
|
@@ -218,6 +496,24 @@ function normalizeMaxPrice(value: string): number {
|
|
|
218
496
|
return numeric;
|
|
219
497
|
}
|
|
220
498
|
|
|
499
|
+
function normalizeAdults(value: string): number {
|
|
500
|
+
const numeric = Number.parseInt(value, 10);
|
|
501
|
+
if (!Number.isInteger(numeric) || numeric <= 0) {
|
|
502
|
+
throw new CliError("--adults must be a positive integer", ExitCode.InvalidInput);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return numeric;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function normalizeMinRating(value: string): 3.5 | 4 | 4.5 | 5 {
|
|
509
|
+
const numeric = Number.parseFloat(value);
|
|
510
|
+
if (numeric !== 3.5 && numeric !== 4 && numeric !== 4.5 && numeric !== 5) {
|
|
511
|
+
throw new CliError("--rating must be one of: 3.5, 4, 4.5, 5", ExitCode.InvalidInput);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return numeric;
|
|
515
|
+
}
|
|
516
|
+
|
|
221
517
|
function normalizeTime(value: string, flagName: string): number {
|
|
222
518
|
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value);
|
|
223
519
|
if (!match) {
|
|
@@ -226,3 +522,12 @@ function normalizeTime(value: string, flagName: string): number {
|
|
|
226
522
|
|
|
227
523
|
return Number(match[1]) * 60 + Number(match[2]);
|
|
228
524
|
}
|
|
525
|
+
|
|
526
|
+
function parseDateOnly(value: string): Date {
|
|
527
|
+
const [year, month, day] = value.split("-").map((part) => Number(part));
|
|
528
|
+
return new Date(year, month - 1, day);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function capitalize(value: string): string {
|
|
532
|
+
return value.slice(0, 1).toUpperCase() + value.slice(1);
|
|
533
|
+
}
|
package/src/serpapi.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { CliError } from "./errors";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
ExitCode,
|
|
4
|
+
FlightBookingQuery,
|
|
5
|
+
FlightBookingResult,
|
|
6
|
+
FlightOption,
|
|
7
|
+
FlightQuery,
|
|
8
|
+
FlightSearchResult,
|
|
9
|
+
HotelOption,
|
|
10
|
+
HotelQuery,
|
|
11
|
+
} from "./types";
|
|
3
12
|
|
|
4
13
|
interface SerpApiAirport {
|
|
5
14
|
time?: string;
|
|
@@ -16,44 +25,70 @@ interface SerpApiItinerary {
|
|
|
16
25
|
price?: number;
|
|
17
26
|
total_duration?: number;
|
|
18
27
|
flights?: SerpApiSegment[];
|
|
28
|
+
booking_token?: string;
|
|
19
29
|
}
|
|
20
30
|
|
|
21
|
-
interface
|
|
31
|
+
interface SerpApiFlightsResponse {
|
|
22
32
|
error?: string;
|
|
33
|
+
search_metadata?: {
|
|
34
|
+
google_flights_url?: string;
|
|
35
|
+
};
|
|
23
36
|
best_flights?: SerpApiItinerary[];
|
|
24
37
|
other_flights?: SerpApiItinerary[];
|
|
25
38
|
}
|
|
26
39
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
): Promise<FlightOption[]> {
|
|
32
|
-
const requestUrl = buildRequestUrl(query, apiKey);
|
|
40
|
+
interface SerpApiBookingRequest {
|
|
41
|
+
url?: string;
|
|
42
|
+
post_data?: string;
|
|
43
|
+
}
|
|
33
44
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (error instanceof Error && error.name === "TimeoutError") {
|
|
41
|
-
throw new CliError("SerpApi request timed out", ExitCode.ApiFailure);
|
|
42
|
-
}
|
|
45
|
+
interface SerpApiBookingNode {
|
|
46
|
+
source?: string;
|
|
47
|
+
price?: number;
|
|
48
|
+
booking_request?: SerpApiBookingRequest;
|
|
49
|
+
[key: string]: unknown;
|
|
50
|
+
}
|
|
43
51
|
|
|
44
|
-
|
|
45
|
-
|
|
52
|
+
interface SerpApiBookingOptionsResponse {
|
|
53
|
+
error?: string;
|
|
54
|
+
search_metadata?: {
|
|
55
|
+
google_flights_url?: string;
|
|
56
|
+
};
|
|
57
|
+
[key: string]: unknown;
|
|
58
|
+
}
|
|
46
59
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
60
|
+
interface SerpApiPrice {
|
|
61
|
+
lowest?: number;
|
|
62
|
+
extracted_lowest?: number;
|
|
63
|
+
}
|
|
50
64
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
65
|
+
interface SerpApiHotelProperty {
|
|
66
|
+
name?: string;
|
|
67
|
+
rate_per_night?: SerpApiPrice;
|
|
68
|
+
total_rate?: SerpApiPrice;
|
|
69
|
+
overall_rating?: number;
|
|
70
|
+
reviews?: number;
|
|
71
|
+
description?: string;
|
|
72
|
+
type?: string;
|
|
73
|
+
link?: string;
|
|
74
|
+
serpapi_property_details_link?: string;
|
|
75
|
+
google_property_details_link?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface SerpApiHotelsResponse {
|
|
79
|
+
error?: string;
|
|
80
|
+
properties?: SerpApiHotelProperty[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function searchFlights(
|
|
84
|
+
query: FlightQuery,
|
|
85
|
+
apiKey: string,
|
|
86
|
+
fetchImpl: typeof fetch = fetch,
|
|
87
|
+
): Promise<FlightSearchResult> {
|
|
88
|
+
const payload = await fetchSerpApiJson<SerpApiFlightsResponse>(
|
|
89
|
+
buildFlightRequestUrl(query, apiKey),
|
|
90
|
+
fetchImpl,
|
|
91
|
+
);
|
|
57
92
|
|
|
58
93
|
if (typeof payload.error === "string" && payload.error.trim() !== "") {
|
|
59
94
|
throw new CliError(`SerpApi error: ${payload.error}`, ExitCode.ApiFailure);
|
|
@@ -72,11 +107,58 @@ export async function searchFlights(
|
|
|
72
107
|
);
|
|
73
108
|
}
|
|
74
109
|
|
|
75
|
-
flights.sort(
|
|
76
|
-
return
|
|
110
|
+
flights.sort(compareByDepartureTime);
|
|
111
|
+
return {
|
|
112
|
+
options: flights,
|
|
113
|
+
googleFlightsUrl: payload.search_metadata?.google_flights_url,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function searchFlightBookingOptions(
|
|
118
|
+
query: FlightBookingQuery,
|
|
119
|
+
apiKey: string,
|
|
120
|
+
fetchImpl: typeof fetch = fetch,
|
|
121
|
+
): Promise<FlightBookingResult[]> {
|
|
122
|
+
const requests = query.tokens.map(async (token) => {
|
|
123
|
+
const payload = await fetchSerpApiJson<SerpApiBookingOptionsResponse>(
|
|
124
|
+
buildFlightBookingOptionsRequestUrl(query, token, apiKey),
|
|
125
|
+
fetchImpl,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
if (typeof payload.error === "string" && payload.error.trim() !== "") {
|
|
129
|
+
throw new CliError(`SerpApi error: ${payload.error}`, ExitCode.ApiFailure);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
token,
|
|
134
|
+
googleFlightsUrl: payload.search_metadata?.google_flights_url,
|
|
135
|
+
links: extractBookingLinks(payload),
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
return Promise.all(requests);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function searchHotels(
|
|
143
|
+
query: HotelQuery,
|
|
144
|
+
apiKey: string,
|
|
145
|
+
fetchImpl: typeof fetch = fetch,
|
|
146
|
+
): Promise<HotelOption[]> {
|
|
147
|
+
const payload = await fetchSerpApiJson<SerpApiHotelsResponse>(
|
|
148
|
+
buildHotelRequestUrl(query, apiKey),
|
|
149
|
+
fetchImpl,
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
if (typeof payload.error === "string" && payload.error.trim() !== "") {
|
|
153
|
+
throw new CliError(`SerpApi error: ${payload.error}`, ExitCode.ApiFailure);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const hotels = shapeHotelSerpApiResponse(payload);
|
|
157
|
+
hotels.sort((a, b) => a.nightlyPrice - b.nightlyPrice);
|
|
158
|
+
return hotels;
|
|
77
159
|
}
|
|
78
160
|
|
|
79
|
-
export function shapeSerpApiResponse(payload:
|
|
161
|
+
export function shapeSerpApiResponse(payload: SerpApiFlightsResponse): FlightOption[] {
|
|
80
162
|
const merged = [...(payload.best_flights ?? []), ...(payload.other_flights ?? [])];
|
|
81
163
|
|
|
82
164
|
return merged
|
|
@@ -84,6 +166,12 @@ export function shapeSerpApiResponse(payload: SerpApiResponse): FlightOption[] {
|
|
|
84
166
|
.filter((option): option is FlightOption => option !== null);
|
|
85
167
|
}
|
|
86
168
|
|
|
169
|
+
export function shapeHotelSerpApiResponse(payload: SerpApiHotelsResponse): HotelOption[] {
|
|
170
|
+
return (payload.properties ?? [])
|
|
171
|
+
.map((property) => shapeHotelProperty(property))
|
|
172
|
+
.filter((option): option is HotelOption => option !== null);
|
|
173
|
+
}
|
|
174
|
+
|
|
87
175
|
export function filterByDepartureWindow(
|
|
88
176
|
options: FlightOption[],
|
|
89
177
|
minMinutes: number,
|
|
@@ -121,7 +209,7 @@ function shapeItinerary(itinerary: SerpApiItinerary): FlightOption | null {
|
|
|
121
209
|
|
|
122
210
|
const uniqueAirlines = [...new Set(segments.map((segment) => segment.airline).filter(Boolean))];
|
|
123
211
|
|
|
124
|
-
|
|
212
|
+
const option: FlightOption = {
|
|
125
213
|
price: itinerary.price as number,
|
|
126
214
|
airline: uniqueAirlines.length > 0 ? uniqueAirlines.join(", ") : "Unknown",
|
|
127
215
|
departureTime,
|
|
@@ -129,6 +217,40 @@ function shapeItinerary(itinerary: SerpApiItinerary): FlightOption | null {
|
|
|
129
217
|
durationMinutes: inferDurationMinutes(itinerary, segments),
|
|
130
218
|
stops: Math.max(0, segments.length - 1),
|
|
131
219
|
};
|
|
220
|
+
|
|
221
|
+
if (typeof itinerary.booking_token === "string" && itinerary.booking_token.trim() !== "") {
|
|
222
|
+
option.bookingToken = itinerary.booking_token.trim();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return option;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function shapeHotelProperty(property: SerpApiHotelProperty): HotelOption | null {
|
|
229
|
+
if (typeof property.name !== "string" || property.name.trim() === "") {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const nightlyPrice = extractLowestPrice(property.rate_per_night);
|
|
234
|
+
if (typeof nightlyPrice !== "number") {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const totalPrice = extractLowestPrice(property.total_rate);
|
|
239
|
+
const rating = Number.isFinite(property.overall_rating) ? property.overall_rating : undefined;
|
|
240
|
+
const reviews = Number.isFinite(property.reviews) ? property.reviews : undefined;
|
|
241
|
+
const location = property.description?.trim() || property.type?.trim() || "n/a";
|
|
242
|
+
const link =
|
|
243
|
+
property.link || property.serpapi_property_details_link || property.google_property_details_link;
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
name: property.name.trim(),
|
|
247
|
+
nightlyPrice,
|
|
248
|
+
totalPrice,
|
|
249
|
+
rating,
|
|
250
|
+
reviews,
|
|
251
|
+
location,
|
|
252
|
+
link,
|
|
253
|
+
};
|
|
132
254
|
}
|
|
133
255
|
|
|
134
256
|
function inferDurationMinutes(itinerary: SerpApiItinerary, segments: SerpApiSegment[]): number {
|
|
@@ -146,7 +268,7 @@ function inferDurationMinutes(itinerary: SerpApiItinerary, segments: SerpApiSegm
|
|
|
146
268
|
return segmentDuration;
|
|
147
269
|
}
|
|
148
270
|
|
|
149
|
-
function
|
|
271
|
+
function buildFlightRequestUrl(query: FlightQuery, apiKey: string): string {
|
|
150
272
|
const url = new URL("https://serpapi.com/search.json");
|
|
151
273
|
|
|
152
274
|
url.searchParams.set("engine", "google_flights");
|
|
@@ -183,6 +305,53 @@ function buildRequestUrl(query: FlightQuery, apiKey: string): string {
|
|
|
183
305
|
url.searchParams.set("outbound_times", `${minHour},${maxHour}`);
|
|
184
306
|
}
|
|
185
307
|
|
|
308
|
+
if (query.excludeBasic) {
|
|
309
|
+
url.searchParams.set("exclude_basic", "true");
|
|
310
|
+
url.searchParams.set("travel_class", "1");
|
|
311
|
+
url.searchParams.set("gl", "us");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return url.toString();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function buildHotelRequestUrl(query: HotelQuery, apiKey: string): string {
|
|
318
|
+
const url = new URL("https://serpapi.com/search.json");
|
|
319
|
+
|
|
320
|
+
url.searchParams.set("engine", "google_hotels");
|
|
321
|
+
url.searchParams.set("q", query.location);
|
|
322
|
+
url.searchParams.set("check_in_date", query.checkInDate);
|
|
323
|
+
url.searchParams.set("check_out_date", query.checkOutDate);
|
|
324
|
+
url.searchParams.set("adults", String(query.adults));
|
|
325
|
+
url.searchParams.set("currency", "USD");
|
|
326
|
+
url.searchParams.set("api_key", apiKey);
|
|
327
|
+
|
|
328
|
+
if (typeof query.maxPrice === "number") {
|
|
329
|
+
url.searchParams.set("max_price", String(query.maxPrice));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (typeof query.minRating === "number") {
|
|
333
|
+
url.searchParams.set("rating", toSerpApiRating(query.minRating));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return url.toString();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function buildFlightBookingOptionsRequestUrl(
|
|
340
|
+
query: FlightBookingQuery,
|
|
341
|
+
token: string,
|
|
342
|
+
apiKey: string,
|
|
343
|
+
): string {
|
|
344
|
+
const url = new URL("https://serpapi.com/search.json");
|
|
345
|
+
|
|
346
|
+
url.searchParams.set("engine", "google_flights");
|
|
347
|
+
url.searchParams.set("type", "2");
|
|
348
|
+
url.searchParams.set("departure_id", query.origin);
|
|
349
|
+
url.searchParams.set("arrival_id", query.destination);
|
|
350
|
+
url.searchParams.set("outbound_date", query.departureDate);
|
|
351
|
+
url.searchParams.set("booking_token", token);
|
|
352
|
+
url.searchParams.set("currency", "USD");
|
|
353
|
+
url.searchParams.set("api_key", apiKey);
|
|
354
|
+
|
|
186
355
|
return url.toString();
|
|
187
356
|
}
|
|
188
357
|
|
|
@@ -198,6 +367,75 @@ function toSerpApiStopsFilter(maxStops: number): string {
|
|
|
198
367
|
return "3";
|
|
199
368
|
}
|
|
200
369
|
|
|
370
|
+
function toSerpApiRating(rating: 3.5 | 4 | 4.5 | 5): string {
|
|
371
|
+
if (rating === 3.5) {
|
|
372
|
+
return "7";
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (rating === 4) {
|
|
376
|
+
return "8";
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (rating === 4.5) {
|
|
380
|
+
return "9";
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return "10";
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function fetchSerpApiJson<T>(
|
|
387
|
+
requestUrl: string,
|
|
388
|
+
fetchImpl: typeof fetch,
|
|
389
|
+
): Promise<T> {
|
|
390
|
+
let response: Response;
|
|
391
|
+
try {
|
|
392
|
+
response = await fetchImpl(requestUrl, {
|
|
393
|
+
signal: AbortSignal.timeout(15_000),
|
|
394
|
+
});
|
|
395
|
+
} catch (error) {
|
|
396
|
+
if (error instanceof Error && error.name === "TimeoutError") {
|
|
397
|
+
throw new CliError("SerpApi request timed out", ExitCode.ApiFailure);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
throw new CliError("Failed to reach SerpApi", ExitCode.ApiFailure);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (!response.ok) {
|
|
404
|
+
let details = "";
|
|
405
|
+
try {
|
|
406
|
+
const raw = await response.text();
|
|
407
|
+
if (raw.trim().length > 0) {
|
|
408
|
+
details = `: ${raw.trim()}`;
|
|
409
|
+
}
|
|
410
|
+
} catch {
|
|
411
|
+
details = "";
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
throw new CliError(
|
|
415
|
+
`SerpApi request failed with status ${response.status}${details}`,
|
|
416
|
+
ExitCode.ApiFailure,
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
try {
|
|
421
|
+
return (await response.json()) as T;
|
|
422
|
+
} catch {
|
|
423
|
+
throw new CliError("SerpApi returned invalid JSON", ExitCode.ApiFailure);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function extractLowestPrice(value?: SerpApiPrice): number | undefined {
|
|
428
|
+
if (Number.isFinite(value?.lowest)) {
|
|
429
|
+
return value?.lowest;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (Number.isFinite(value?.extracted_lowest)) {
|
|
433
|
+
return value?.extracted_lowest;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return undefined;
|
|
437
|
+
}
|
|
438
|
+
|
|
201
439
|
function extractMinutes(value: string): number | null {
|
|
202
440
|
const match = /\b([01]?\d|2[0-3]):([0-5]\d)\b/.exec(value);
|
|
203
441
|
if (!match) {
|
|
@@ -206,3 +444,89 @@ function extractMinutes(value: string): number | null {
|
|
|
206
444
|
|
|
207
445
|
return Number(match[1]) * 60 + Number(match[2]);
|
|
208
446
|
}
|
|
447
|
+
|
|
448
|
+
function compareByDepartureTime(a: FlightOption, b: FlightOption): number {
|
|
449
|
+
const aKey = departureSortKey(a.departureTime);
|
|
450
|
+
const bKey = departureSortKey(b.departureTime);
|
|
451
|
+
|
|
452
|
+
if (aKey !== bKey) {
|
|
453
|
+
return aKey - bKey;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return a.price - b.price;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function departureSortKey(value: string): number {
|
|
460
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})\s+([01]\d|2[0-3]):([0-5]\d)$/.exec(value.trim());
|
|
461
|
+
if (!match) {
|
|
462
|
+
return Number.MAX_SAFE_INTEGER;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const year = Number(match[1]);
|
|
466
|
+
const month = Number(match[2]);
|
|
467
|
+
const day = Number(match[3]);
|
|
468
|
+
const hour = Number(match[4]);
|
|
469
|
+
const minute = Number(match[5]);
|
|
470
|
+
|
|
471
|
+
return (
|
|
472
|
+
year * 100000000 +
|
|
473
|
+
month * 1000000 +
|
|
474
|
+
day * 10000 +
|
|
475
|
+
hour * 100 +
|
|
476
|
+
minute
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function extractBookingLinks(payload: SerpApiBookingOptionsResponse): Array<{
|
|
481
|
+
url: string;
|
|
482
|
+
source?: string;
|
|
483
|
+
price?: number;
|
|
484
|
+
}> {
|
|
485
|
+
const links: Array<{ url: string; source?: string; price?: number }> = [];
|
|
486
|
+
collectBookingLinks(payload as SerpApiBookingNode, links);
|
|
487
|
+
|
|
488
|
+
const deduped = new Map<string, { url: string; source?: string; price?: number }>();
|
|
489
|
+
for (const link of links) {
|
|
490
|
+
if (!deduped.has(link.url)) {
|
|
491
|
+
deduped.set(link.url, link);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return [...deduped.values()];
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function collectBookingLinks(
|
|
499
|
+
node: unknown,
|
|
500
|
+
links: Array<{ url: string; source?: string; price?: number }>,
|
|
501
|
+
): void {
|
|
502
|
+
if (Array.isArray(node)) {
|
|
503
|
+
for (const item of node) {
|
|
504
|
+
collectBookingLinks(item, links);
|
|
505
|
+
}
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (!node || typeof node !== "object") {
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const value = node as SerpApiBookingNode;
|
|
514
|
+
const bookingUrl = value.booking_request?.url;
|
|
515
|
+
const bookingPostData = value.booking_request?.post_data;
|
|
516
|
+
if (typeof bookingUrl === "string" && bookingUrl.trim() !== "") {
|
|
517
|
+
const finalUrl =
|
|
518
|
+
typeof bookingPostData === "string" && bookingPostData.trim() !== ""
|
|
519
|
+
? `${bookingUrl}${bookingUrl.includes("?") ? "&" : "?"}${bookingPostData}`
|
|
520
|
+
: bookingUrl;
|
|
521
|
+
|
|
522
|
+
links.push({
|
|
523
|
+
url: finalUrl,
|
|
524
|
+
source: typeof value.source === "string" ? value.source : undefined,
|
|
525
|
+
price: Number.isFinite(value.price) ? value.price : undefined,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
for (const child of Object.values(value)) {
|
|
530
|
+
collectBookingLinks(child, links);
|
|
531
|
+
}
|
|
532
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -18,14 +18,57 @@ 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";
|
|
41
|
+
outputJson: boolean;
|
|
42
|
+
query: FlightQuery;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ParsedArgsHotels {
|
|
46
|
+
help: false;
|
|
47
|
+
mode: "hotels";
|
|
48
|
+
outputJson: boolean;
|
|
49
|
+
query: HotelQuery;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface FlightBookingQuery {
|
|
53
|
+
origin: string;
|
|
54
|
+
destination: string;
|
|
55
|
+
departureDate: string;
|
|
56
|
+
tokens: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ParsedArgsFlightBooking {
|
|
60
|
+
help: false;
|
|
61
|
+
mode: "flight-booking";
|
|
25
62
|
outputJson: boolean;
|
|
26
|
-
|
|
63
|
+
query: FlightBookingQuery;
|
|
27
64
|
}
|
|
28
65
|
|
|
66
|
+
export type ParsedArgs =
|
|
67
|
+
| ParsedArgsHelp
|
|
68
|
+
| ParsedArgsFlights
|
|
69
|
+
| ParsedArgsHotels
|
|
70
|
+
| ParsedArgsFlightBooking;
|
|
71
|
+
|
|
29
72
|
export interface FlightOption {
|
|
30
73
|
price: number;
|
|
31
74
|
airline: string;
|
|
@@ -33,4 +76,32 @@ export interface FlightOption {
|
|
|
33
76
|
arrivalTime: string;
|
|
34
77
|
durationMinutes: number;
|
|
35
78
|
stops: number;
|
|
79
|
+
bookingToken?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface HotelOption {
|
|
83
|
+
name: string;
|
|
84
|
+
nightlyPrice: number;
|
|
85
|
+
totalPrice?: number;
|
|
86
|
+
rating?: number;
|
|
87
|
+
reviews?: number;
|
|
88
|
+
location: string;
|
|
89
|
+
link?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface FlightSearchResult {
|
|
93
|
+
options: FlightOption[];
|
|
94
|
+
googleFlightsUrl?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface FlightBookingLink {
|
|
98
|
+
url: string;
|
|
99
|
+
source?: string;
|
|
100
|
+
price?: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface FlightBookingResult {
|
|
104
|
+
token: string;
|
|
105
|
+
googleFlightsUrl?: string;
|
|
106
|
+
links: FlightBookingLink[];
|
|
36
107
|
}
|