@tks/wayfinder 0.3.1 → 0.4.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 +6 -0
- package/package.json +1 -1
- package/src/cli.ts +50 -22
- package/src/format.ts +14 -1
- package/src/parse.ts +27 -8
- package/src/serpapi.ts +65 -0
- package/src/types.ts +25 -1
package/README.md
CHANGED
|
@@ -58,6 +58,12 @@ Search one way flights:
|
|
|
58
58
|
wayfinder flights --from SFO --to JFK --date 2026-04-10
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
+
Search up to 3 departure dates in one command:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
wayfinder flights --from SFO --to JFK --date 2026-04-10 --date 2026-04-11 --date 2026-04-13
|
|
65
|
+
```
|
|
66
|
+
|
|
61
67
|
Search with filters:
|
|
62
68
|
|
|
63
69
|
```bash
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { getConfigPath, readConfigApiKey, resolveApiKey, writeConfigApiKey } from "./config";
|
|
2
2
|
import { CliError } from "./errors";
|
|
3
|
-
import { renderFlightTable, renderHotelTable, renderPlaceTable } from "./format";
|
|
3
|
+
import { renderFlightTable, renderFlightTablesByDate, renderHotelTable, renderPlaceTable } from "./format";
|
|
4
4
|
import { parseCliArgs } from "./parse";
|
|
5
|
-
import { searchFlightBookingOptions, searchFlights, searchHotels, searchPlaces } from "./serpapi";
|
|
6
|
-
import { ExitCode } from "./types";
|
|
5
|
+
import { searchFlightBookingOptions, searchFlights, searchFlightsMultiDate, searchHotels, searchPlaces } from "./serpapi";
|
|
6
|
+
import { ExitCode, FlightMultiDateQuery, FlightsQuery } from "./types";
|
|
7
7
|
import { createInterface } from "node:readline/promises";
|
|
8
8
|
import { stdin as defaultStdin, stdout as defaultStdout } from "node:process";
|
|
9
9
|
import { existsSync, unlinkSync } from "node:fs";
|
|
@@ -22,7 +22,7 @@ interface RunOptions {
|
|
|
22
22
|
promptImpl?: (prompt: string) => Promise<string>;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
const HELP_TEXT = `wayfinder v0.
|
|
25
|
+
const HELP_TEXT = `wayfinder v0.4.0 travel search
|
|
26
26
|
|
|
27
27
|
Usage:
|
|
28
28
|
wayfinder setup [--reset]
|
|
@@ -39,7 +39,7 @@ Setup:
|
|
|
39
39
|
Flights required:
|
|
40
40
|
--from <IATA> Origin airport code
|
|
41
41
|
--to <IATA> Destination airport code
|
|
42
|
-
--date <YYYY-MM-DD> Departure date
|
|
42
|
+
--date <YYYY-MM-DD> Departure date (repeat up to 3 unique dates)
|
|
43
43
|
|
|
44
44
|
Flights optional filters:
|
|
45
45
|
--airline <IATA> Airline code, example UA
|
|
@@ -135,26 +135,50 @@ export async function runWayfinder(
|
|
|
135
135
|
|
|
136
136
|
const apiKey = resolveApiKey(env, homeDir);
|
|
137
137
|
if (parsed.mode === "flights") {
|
|
138
|
-
|
|
138
|
+
if (isMultiDateFlightQuery(parsed.query)) {
|
|
139
|
+
const multiDateFlights = await searchFlightsMultiDate(parsed.query, apiKey, options.fetchImpl ?? fetch);
|
|
140
|
+
const nonEmptyDateResults = multiDateFlights.resultsByDate.filter((entry) => entry.results.length > 0);
|
|
139
141
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
142
|
+
if (nonEmptyDateResults.length === 0) {
|
|
143
|
+
throw new CliError("No flights found for the selected query", ExitCode.NoResults);
|
|
144
|
+
}
|
|
143
145
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
)
|
|
155
|
-
|
|
146
|
+
if (parsed.outputJson) {
|
|
147
|
+
output.stdout(
|
|
148
|
+
JSON.stringify(
|
|
149
|
+
{
|
|
150
|
+
query: parsed.query,
|
|
151
|
+
resultsByDate: nonEmptyDateResults,
|
|
152
|
+
},
|
|
153
|
+
null,
|
|
154
|
+
2,
|
|
155
|
+
),
|
|
156
|
+
);
|
|
157
|
+
} else {
|
|
158
|
+
output.stdout(renderFlightTablesByDate(nonEmptyDateResults));
|
|
159
|
+
}
|
|
156
160
|
} else {
|
|
157
|
-
|
|
161
|
+
const flights = await searchFlights(parsed.query, apiKey, options.fetchImpl ?? fetch);
|
|
162
|
+
|
|
163
|
+
if (flights.options.length === 0) {
|
|
164
|
+
throw new CliError("No flights found for the selected query", ExitCode.NoResults);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (parsed.outputJson) {
|
|
168
|
+
output.stdout(
|
|
169
|
+
JSON.stringify(
|
|
170
|
+
{
|
|
171
|
+
query: parsed.query,
|
|
172
|
+
googleFlightsUrl: flights.googleFlightsUrl,
|
|
173
|
+
results: flights.options,
|
|
174
|
+
},
|
|
175
|
+
null,
|
|
176
|
+
2,
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
} else {
|
|
180
|
+
output.stdout(renderFlightTable(flights.options));
|
|
181
|
+
}
|
|
158
182
|
}
|
|
159
183
|
} else if (parsed.mode === "flight-booking") {
|
|
160
184
|
const bookingResults = await searchFlightBookingOptions(
|
|
@@ -340,3 +364,7 @@ async function promptWithFallback(
|
|
|
340
364
|
rl.close();
|
|
341
365
|
}
|
|
342
366
|
}
|
|
367
|
+
|
|
368
|
+
function isMultiDateFlightQuery(query: FlightsQuery): query is FlightMultiDateQuery {
|
|
369
|
+
return "departureDates" in query;
|
|
370
|
+
}
|
package/src/format.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FlightOption, HotelOption, PlaceOption } from "./types";
|
|
1
|
+
import { FlightDateResult, FlightOption, HotelOption, PlaceOption } from "./types";
|
|
2
2
|
|
|
3
3
|
const currencyFormatter = new Intl.NumberFormat("en-US", {
|
|
4
4
|
style: "currency",
|
|
@@ -74,6 +74,19 @@ export function renderFlightTable(options: FlightOption[]): string {
|
|
|
74
74
|
return lines.join("\n");
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
export function renderFlightTablesByDate(resultsByDate: FlightDateResult[]): string {
|
|
78
|
+
const sections = resultsByDate.map((result) => {
|
|
79
|
+
const lines = [`DATE: ${result.date}`];
|
|
80
|
+
if (typeof result.googleFlightsUrl === "string") {
|
|
81
|
+
lines.push(`GOOGLE FLIGHTS: ${result.googleFlightsUrl}`);
|
|
82
|
+
}
|
|
83
|
+
lines.push(renderFlightTable(result.results));
|
|
84
|
+
return lines.join("\n");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return sections.join("\n\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
77
90
|
export function renderHotelTable(options: HotelOption[]): string {
|
|
78
91
|
const rows = options.map((option) => ({
|
|
79
92
|
nightly: currencyFormatter.format(option.nightlyPrice),
|
package/src/parse.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { CliError } from "./errors";
|
|
|
2
2
|
import {
|
|
3
3
|
ExitCode,
|
|
4
4
|
FlightBookingQuery,
|
|
5
|
-
|
|
5
|
+
FlightsQuery,
|
|
6
6
|
HotelQuery,
|
|
7
7
|
ParsedArgs,
|
|
8
8
|
PlaceQuery,
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
interface FlightRawOptions {
|
|
14
14
|
from?: string;
|
|
15
15
|
to?: string;
|
|
16
|
-
|
|
16
|
+
dates: string[];
|
|
17
17
|
airline?: string;
|
|
18
18
|
maxStops?: string;
|
|
19
19
|
maxPrice?: string;
|
|
@@ -88,6 +88,7 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
|
|
|
88
88
|
|
|
89
89
|
function parseFlightsArgs(args: string[]): ParsedArgs {
|
|
90
90
|
const raw: FlightRawOptions = {
|
|
91
|
+
dates: [],
|
|
91
92
|
excludeBasic: false,
|
|
92
93
|
outputJson: false,
|
|
93
94
|
help: false,
|
|
@@ -128,7 +129,7 @@ function parseFlightsArgs(args: string[]): ParsedArgs {
|
|
|
128
129
|
raw.to = value;
|
|
129
130
|
break;
|
|
130
131
|
case "--date":
|
|
131
|
-
raw.
|
|
132
|
+
raw.dates.push(value);
|
|
132
133
|
break;
|
|
133
134
|
case "--airline":
|
|
134
135
|
raw.airline = value;
|
|
@@ -382,8 +383,8 @@ function parseSetupArgs(args: string[]): ParsedArgs {
|
|
|
382
383
|
};
|
|
383
384
|
}
|
|
384
385
|
|
|
385
|
-
function buildFlightQuery(raw: FlightRawOptions):
|
|
386
|
-
if (!raw.from || !raw.to ||
|
|
386
|
+
function buildFlightQuery(raw: FlightRawOptions): FlightsQuery {
|
|
387
|
+
if (!raw.from || !raw.to || raw.dates.length === 0) {
|
|
387
388
|
throw new CliError("Missing required flags: --from, --to, --date", ExitCode.InvalidInput);
|
|
388
389
|
}
|
|
389
390
|
|
|
@@ -394,7 +395,14 @@ function buildFlightQuery(raw: FlightRawOptions): FlightQuery {
|
|
|
394
395
|
throw new CliError("Origin and destination must be different airports", ExitCode.InvalidInput);
|
|
395
396
|
}
|
|
396
397
|
|
|
397
|
-
const
|
|
398
|
+
const departureDates = [...new Set(raw.dates.map((date) => normalizeDate(date, "departure")))];
|
|
399
|
+
if (departureDates.length > 3) {
|
|
400
|
+
throw new CliError(
|
|
401
|
+
"Too many dates: maximum 3 unique --date values per search",
|
|
402
|
+
ExitCode.InvalidInput,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
398
406
|
const airlineCode = raw.airline ? normalizeAirlineCode(raw.airline) : undefined;
|
|
399
407
|
const maxStops = raw.maxStops ? normalizeMaxStops(raw.maxStops) : undefined;
|
|
400
408
|
const maxPrice = raw.maxPrice ? normalizeMaxPrice(raw.maxPrice) : undefined;
|
|
@@ -424,10 +432,9 @@ function buildFlightQuery(raw: FlightRawOptions): FlightQuery {
|
|
|
424
432
|
}
|
|
425
433
|
}
|
|
426
434
|
|
|
427
|
-
|
|
435
|
+
const shared = {
|
|
428
436
|
origin,
|
|
429
437
|
destination,
|
|
430
|
-
departureDate,
|
|
431
438
|
airlineCode,
|
|
432
439
|
maxStops,
|
|
433
440
|
maxPrice,
|
|
@@ -435,6 +442,18 @@ function buildFlightQuery(raw: FlightRawOptions): FlightQuery {
|
|
|
435
442
|
departureBeforeMinutes,
|
|
436
443
|
excludeBasic: raw.excludeBasic || undefined,
|
|
437
444
|
};
|
|
445
|
+
|
|
446
|
+
if (departureDates.length === 1) {
|
|
447
|
+
return {
|
|
448
|
+
...shared,
|
|
449
|
+
departureDate: departureDates[0] as string,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return {
|
|
454
|
+
...shared,
|
|
455
|
+
departureDates,
|
|
456
|
+
};
|
|
438
457
|
}
|
|
439
458
|
|
|
440
459
|
function buildHotelQuery(raw: HotelRawOptions): HotelQuery {
|
package/src/serpapi.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { CliError } from "./errors";
|
|
2
2
|
import {
|
|
3
3
|
ExitCode,
|
|
4
|
+
FlightDateResult,
|
|
4
5
|
FlightBookingQuery,
|
|
5
6
|
FlightBookingResult,
|
|
7
|
+
FlightMultiDateQuery,
|
|
8
|
+
FlightMultiDateSearchResult,
|
|
6
9
|
FlightOption,
|
|
7
10
|
FlightQuery,
|
|
8
11
|
FlightSearchResult,
|
|
@@ -134,6 +137,38 @@ export async function searchFlights(
|
|
|
134
137
|
};
|
|
135
138
|
}
|
|
136
139
|
|
|
140
|
+
export async function searchFlightsMultiDate(
|
|
141
|
+
query: FlightMultiDateQuery,
|
|
142
|
+
apiKey: string,
|
|
143
|
+
fetchImpl: typeof fetch = fetch,
|
|
144
|
+
): Promise<FlightMultiDateSearchResult> {
|
|
145
|
+
const resultsByDate = await mapWithConcurrency(query.departureDates, 2, async (date) => {
|
|
146
|
+
const singleDateQuery: FlightQuery = {
|
|
147
|
+
origin: query.origin,
|
|
148
|
+
destination: query.destination,
|
|
149
|
+
departureDate: date,
|
|
150
|
+
airlineCode: query.airlineCode,
|
|
151
|
+
maxStops: query.maxStops,
|
|
152
|
+
maxPrice: query.maxPrice,
|
|
153
|
+
departureAfterMinutes: query.departureAfterMinutes,
|
|
154
|
+
departureBeforeMinutes: query.departureBeforeMinutes,
|
|
155
|
+
excludeBasic: query.excludeBasic,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const result = await searchFlights(singleDateQuery, apiKey, fetchImpl);
|
|
159
|
+
const grouped: FlightDateResult = {
|
|
160
|
+
date,
|
|
161
|
+
results: result.options,
|
|
162
|
+
googleFlightsUrl: result.googleFlightsUrl,
|
|
163
|
+
};
|
|
164
|
+
return grouped;
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
resultsByDate,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
137
172
|
export async function searchFlightBookingOptions(
|
|
138
173
|
query: FlightBookingQuery,
|
|
139
174
|
apiKey: string,
|
|
@@ -685,3 +720,33 @@ function collectBookingLinks(
|
|
|
685
720
|
collectBookingLinks(child, links);
|
|
686
721
|
}
|
|
687
722
|
}
|
|
723
|
+
|
|
724
|
+
async function mapWithConcurrency<TInput, TOutput>(
|
|
725
|
+
items: TInput[],
|
|
726
|
+
concurrency: number,
|
|
727
|
+
worker: (item: TInput, index: number) => Promise<TOutput>,
|
|
728
|
+
): Promise<TOutput[]> {
|
|
729
|
+
const output: TOutput[] = new Array(items.length);
|
|
730
|
+
let cursor = 0;
|
|
731
|
+
|
|
732
|
+
async function runWorker(): Promise<void> {
|
|
733
|
+
while (true) {
|
|
734
|
+
const index = cursor;
|
|
735
|
+
cursor += 1;
|
|
736
|
+
|
|
737
|
+
if (index >= items.length) {
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
output[index] = await worker(items[index] as TInput, index);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const workers = Array.from(
|
|
746
|
+
{ length: Math.min(Math.max(concurrency, 1), items.length) },
|
|
747
|
+
() => runWorker(),
|
|
748
|
+
);
|
|
749
|
+
|
|
750
|
+
await Promise.all(workers);
|
|
751
|
+
return output;
|
|
752
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -21,6 +21,20 @@ export interface FlightQuery {
|
|
|
21
21
|
excludeBasic?: boolean;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
export interface FlightMultiDateQuery {
|
|
25
|
+
origin: string;
|
|
26
|
+
destination: string;
|
|
27
|
+
departureDates: string[];
|
|
28
|
+
airlineCode?: string;
|
|
29
|
+
maxStops?: number;
|
|
30
|
+
maxPrice?: number;
|
|
31
|
+
departureAfterMinutes?: number;
|
|
32
|
+
departureBeforeMinutes?: number;
|
|
33
|
+
excludeBasic?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type FlightsQuery = FlightQuery | FlightMultiDateQuery;
|
|
37
|
+
|
|
24
38
|
export interface HotelQuery {
|
|
25
39
|
location: string;
|
|
26
40
|
checkInDate: string;
|
|
@@ -49,7 +63,7 @@ export interface ParsedArgsFlights {
|
|
|
49
63
|
help: false;
|
|
50
64
|
mode: "flights";
|
|
51
65
|
outputJson: boolean;
|
|
52
|
-
query:
|
|
66
|
+
query: FlightsQuery;
|
|
53
67
|
}
|
|
54
68
|
|
|
55
69
|
export interface ParsedArgsHotels {
|
|
@@ -133,6 +147,16 @@ export interface FlightSearchResult {
|
|
|
133
147
|
googleFlightsUrl?: string;
|
|
134
148
|
}
|
|
135
149
|
|
|
150
|
+
export interface FlightDateResult {
|
|
151
|
+
date: string;
|
|
152
|
+
results: FlightOption[];
|
|
153
|
+
googleFlightsUrl?: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface FlightMultiDateSearchResult {
|
|
157
|
+
resultsByDate: FlightDateResult[];
|
|
158
|
+
}
|
|
159
|
+
|
|
136
160
|
export interface FlightBookingLink {
|
|
137
161
|
url: string;
|
|
138
162
|
source?: string;
|