@tks/wayfinder 0.2.3 → 0.3.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 +30 -0
- package/package.json +1 -1
- package/src/cli.ts +35 -4
- package/src/format.ts +82 -1
- package/src/parse.ts +138 -3
- package/src/serpapi.ts +155 -0
- package/src/types.ts +31 -0
package/README.md
CHANGED
|
@@ -99,3 +99,33 @@ Structured hotel output for scripting:
|
|
|
99
99
|
```bash
|
|
100
100
|
wayfinder hotels --where "Paris" --check-in 2026-04-10 --check-out 2026-04-12 --json | jq '.results[] | {name,nightlyPrice,rating}'
|
|
101
101
|
```
|
|
102
|
+
|
|
103
|
+
Search nearby restaurants from a location:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
wayfinder places --near "Shinjuku, Tokyo"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Use a specific location name for better relevance:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
wayfinder places --near "Domino Park, Brooklyn, NY"
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Search nearby coffee spots:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
wayfinder places --near "Shinjuku, Tokyo" --type coffee --limit 5
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Narrow results to walking-distance intent:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
wayfinder places --near "Domino Park, Brooklyn, NY" --range walk
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Structured places output for scripting:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
wayfinder places --near "Shinjuku, Tokyo" --type coffee --json | jq '.results[] | {name,rating,reviews,googleMapsUrl}'
|
|
131
|
+
```
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { getConfigPath, readConfigApiKey, resolveApiKey, writeConfigApiKey } from "./config";
|
|
2
2
|
import { CliError } from "./errors";
|
|
3
|
-
import { renderFlightTable, renderHotelTable } from "./format";
|
|
3
|
+
import { renderFlightTable, renderHotelTable, renderPlaceTable } from "./format";
|
|
4
4
|
import { parseCliArgs } from "./parse";
|
|
5
|
-
import { searchFlightBookingOptions, searchFlights, searchHotels } from "./serpapi";
|
|
5
|
+
import { searchFlightBookingOptions, searchFlights, searchHotels, searchPlaces } from "./serpapi";
|
|
6
6
|
import { ExitCode } from "./types";
|
|
7
7
|
import { createInterface } from "node:readline/promises";
|
|
8
8
|
import { stdin as defaultStdin, stdout as defaultStdout } from "node:process";
|
|
@@ -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.3.1 travel search
|
|
26
26
|
|
|
27
27
|
Usage:
|
|
28
28
|
wayfinder setup [--reset]
|
|
@@ -30,6 +30,7 @@ Usage:
|
|
|
30
30
|
wayfinder flights one-way --from SFO --to JFK --date 2026-03-21 [filters]
|
|
31
31
|
wayfinder flights booking --from SFO --to JFK --date 2026-03-21 --token <BOOKING_TOKEN> [--token <BOOKING_TOKEN>] [--json]
|
|
32
32
|
wayfinder hotels --where "New York, NY" --check-in 2026-03-21 --check-out 2026-03-23 [filters]
|
|
33
|
+
wayfinder places --near "Shinjuku, Tokyo" [--type restaurant|coffee] [--range walk] [--limit N] [--json]
|
|
33
34
|
|
|
34
35
|
Setup:
|
|
35
36
|
Runs interactive key setup and stores your SerpApi key in local config.
|
|
@@ -65,6 +66,15 @@ Hotels optional filters:
|
|
|
65
66
|
--max-price <USD> Max nightly rate in USD
|
|
66
67
|
--rating <3.5|4|4.5|5> Minimum guest rating
|
|
67
68
|
|
|
69
|
+
Places required:
|
|
70
|
+
--near <QUERY> Specific location query, example "Domino Park, Brooklyn, NY"
|
|
71
|
+
Broad names can return mixed-city results
|
|
72
|
+
|
|
73
|
+
Places optional filters:
|
|
74
|
+
--type <restaurant|coffee> Place type (default restaurant)
|
|
75
|
+
--range <walk> Heuristic nearby scope; "walk" narrows to walking-distance intent
|
|
76
|
+
--limit <N> Maximum number of results (default 10)
|
|
77
|
+
|
|
68
78
|
Output:
|
|
69
79
|
--json Print structured JSON output`;
|
|
70
80
|
|
|
@@ -181,7 +191,7 @@ export async function runWayfinder(
|
|
|
181
191
|
} else {
|
|
182
192
|
output.stdout(renderFlightBookingText(flightLinks));
|
|
183
193
|
}
|
|
184
|
-
} else {
|
|
194
|
+
} else if (parsed.mode === "hotels") {
|
|
185
195
|
const hotels = await searchHotels(parsed.query, apiKey, options.fetchImpl ?? fetch);
|
|
186
196
|
|
|
187
197
|
if (hotels.length === 0) {
|
|
@@ -202,6 +212,27 @@ export async function runWayfinder(
|
|
|
202
212
|
} else {
|
|
203
213
|
output.stdout(renderHotelTable(hotels));
|
|
204
214
|
}
|
|
215
|
+
} else {
|
|
216
|
+
const places = await searchPlaces(parsed.query, apiKey, options.fetchImpl ?? fetch);
|
|
217
|
+
|
|
218
|
+
if (places.length === 0) {
|
|
219
|
+
throw new CliError("No places found for the selected query", ExitCode.NoResults);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (parsed.outputJson) {
|
|
223
|
+
output.stdout(
|
|
224
|
+
JSON.stringify(
|
|
225
|
+
{
|
|
226
|
+
query: parsed.query,
|
|
227
|
+
results: places,
|
|
228
|
+
},
|
|
229
|
+
null,
|
|
230
|
+
2,
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
} else {
|
|
234
|
+
output.stdout(renderPlaceTable(places));
|
|
235
|
+
}
|
|
205
236
|
}
|
|
206
237
|
|
|
207
238
|
return ExitCode.Success;
|
package/src/format.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FlightOption, HotelOption } from "./types";
|
|
1
|
+
import { FlightOption, HotelOption, PlaceOption } from "./types";
|
|
2
2
|
|
|
3
3
|
const currencyFormatter = new Intl.NumberFormat("en-US", {
|
|
4
4
|
style: "currency",
|
|
@@ -142,6 +142,74 @@ export function renderHotelTable(options: HotelOption[]): string {
|
|
|
142
142
|
return lines.join("\n");
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
export function renderPlaceTable(options: PlaceOption[]): string {
|
|
146
|
+
const rows = options.map((option) => ({
|
|
147
|
+
name: option.name,
|
|
148
|
+
type: option.category,
|
|
149
|
+
rating: typeof option.rating === "number" ? option.rating.toFixed(1) : "n/a",
|
|
150
|
+
reviews: typeof option.reviews === "number" ? String(option.reviews) : "n/a",
|
|
151
|
+
distance: formatDistance(option.distanceMeters),
|
|
152
|
+
address: option.address ?? "n/a",
|
|
153
|
+
}));
|
|
154
|
+
|
|
155
|
+
const headers = {
|
|
156
|
+
name: "NAME",
|
|
157
|
+
type: "TYPE",
|
|
158
|
+
rating: "RATING",
|
|
159
|
+
reviews: "REVIEWS",
|
|
160
|
+
distance: "DISTANCE",
|
|
161
|
+
address: "ADDRESS",
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const widths = {
|
|
165
|
+
name: maxWidth(rows, "name", headers.name),
|
|
166
|
+
type: maxWidth(rows, "type", headers.type),
|
|
167
|
+
rating: maxWidth(rows, "rating", headers.rating),
|
|
168
|
+
reviews: maxWidth(rows, "reviews", headers.reviews),
|
|
169
|
+
distance: maxWidth(rows, "distance", headers.distance),
|
|
170
|
+
address: maxWidth(rows, "address", headers.address),
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const lines: string[] = [];
|
|
174
|
+
|
|
175
|
+
lines.push(
|
|
176
|
+
[
|
|
177
|
+
headers.name.padEnd(widths.name),
|
|
178
|
+
headers.type.padEnd(widths.type),
|
|
179
|
+
headers.rating.padEnd(widths.rating),
|
|
180
|
+
headers.reviews.padEnd(widths.reviews),
|
|
181
|
+
headers.distance.padEnd(widths.distance),
|
|
182
|
+
headers.address.padEnd(widths.address),
|
|
183
|
+
].join(" "),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
lines.push(
|
|
187
|
+
[
|
|
188
|
+
"-".repeat(widths.name),
|
|
189
|
+
"-".repeat(widths.type),
|
|
190
|
+
"-".repeat(widths.rating),
|
|
191
|
+
"-".repeat(widths.reviews),
|
|
192
|
+
"-".repeat(widths.distance),
|
|
193
|
+
"-".repeat(widths.address),
|
|
194
|
+
].join(" "),
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
for (const row of rows) {
|
|
198
|
+
lines.push(
|
|
199
|
+
[
|
|
200
|
+
row.name.padEnd(widths.name),
|
|
201
|
+
row.type.padEnd(widths.type),
|
|
202
|
+
row.rating.padEnd(widths.rating),
|
|
203
|
+
row.reviews.padEnd(widths.reviews),
|
|
204
|
+
row.distance.padEnd(widths.distance),
|
|
205
|
+
row.address.padEnd(widths.address),
|
|
206
|
+
].join(" "),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return lines.join("\n");
|
|
211
|
+
}
|
|
212
|
+
|
|
145
213
|
function maxWidth(rows: Array<Record<string, string>>, key: string, header: string): number {
|
|
146
214
|
return rows.reduce((width, row) => Math.max(width, row[key].length), header.length);
|
|
147
215
|
}
|
|
@@ -164,3 +232,16 @@ function formatDuration(totalMinutes: number): string {
|
|
|
164
232
|
|
|
165
233
|
return `${hours}h ${minutes}m`;
|
|
166
234
|
}
|
|
235
|
+
|
|
236
|
+
function formatDistance(distanceMeters?: number): string {
|
|
237
|
+
if (!Number.isFinite(distanceMeters)) {
|
|
238
|
+
return "n/a";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if ((distanceMeters as number) < 1000) {
|
|
242
|
+
return `${Math.round(distanceMeters as number)}m`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const km = (distanceMeters as number) / 1000;
|
|
246
|
+
return `${km.toFixed(1)}km`;
|
|
247
|
+
}
|
package/src/parse.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { CliError } from "./errors";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
ExitCode,
|
|
4
|
+
FlightBookingQuery,
|
|
5
|
+
FlightQuery,
|
|
6
|
+
HotelQuery,
|
|
7
|
+
ParsedArgs,
|
|
8
|
+
PlaceQuery,
|
|
9
|
+
PlaceRange,
|
|
10
|
+
PlaceType,
|
|
11
|
+
} from "./types";
|
|
3
12
|
|
|
4
13
|
interface FlightRawOptions {
|
|
5
14
|
from?: string;
|
|
@@ -35,7 +44,16 @@ interface FlightBookingRawOptions {
|
|
|
35
44
|
help: boolean;
|
|
36
45
|
}
|
|
37
46
|
|
|
38
|
-
|
|
47
|
+
interface PlaceRawOptions {
|
|
48
|
+
near?: string;
|
|
49
|
+
type?: string;
|
|
50
|
+
limit?: string;
|
|
51
|
+
range?: string;
|
|
52
|
+
outputJson: boolean;
|
|
53
|
+
help: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type SearchMode = "flights" | "hotels" | "places" | "flight-booking" | "setup";
|
|
39
57
|
|
|
40
58
|
const HELP_FLAGS = new Set(["-h", "--help"]);
|
|
41
59
|
|
|
@@ -57,6 +75,10 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
|
|
|
57
75
|
return parseFlightBookingArgs(args);
|
|
58
76
|
}
|
|
59
77
|
|
|
78
|
+
if (mode === "places") {
|
|
79
|
+
return parsePlacesArgs(args);
|
|
80
|
+
}
|
|
81
|
+
|
|
60
82
|
if (mode === "setup") {
|
|
61
83
|
return parseSetupArgs(args);
|
|
62
84
|
}
|
|
@@ -281,6 +303,69 @@ function parseFlightBookingArgs(args: string[]): ParsedArgs {
|
|
|
281
303
|
};
|
|
282
304
|
}
|
|
283
305
|
|
|
306
|
+
function parsePlacesArgs(args: string[]): ParsedArgs {
|
|
307
|
+
const raw: PlaceRawOptions = {
|
|
308
|
+
outputJson: false,
|
|
309
|
+
help: false,
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
313
|
+
const token = args[i];
|
|
314
|
+
|
|
315
|
+
if (HELP_FLAGS.has(token)) {
|
|
316
|
+
raw.help = true;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (token === "--json") {
|
|
321
|
+
raw.outputJson = true;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!token.startsWith("--")) {
|
|
326
|
+
throw new CliError(`Unexpected argument: ${token}`, ExitCode.InvalidInput);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const value = args[i + 1];
|
|
330
|
+
if (!value || value.startsWith("--")) {
|
|
331
|
+
throw new CliError(`Missing value for ${token}`, ExitCode.InvalidInput);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
switch (token) {
|
|
335
|
+
case "--near":
|
|
336
|
+
raw.near = value;
|
|
337
|
+
break;
|
|
338
|
+
case "--type":
|
|
339
|
+
raw.type = value;
|
|
340
|
+
break;
|
|
341
|
+
case "--limit":
|
|
342
|
+
raw.limit = value;
|
|
343
|
+
break;
|
|
344
|
+
case "--range":
|
|
345
|
+
raw.range = value;
|
|
346
|
+
break;
|
|
347
|
+
default:
|
|
348
|
+
throw new CliError(`Unknown flag: ${token}`, ExitCode.InvalidInput);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
i += 1;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (raw.help) {
|
|
355
|
+
return {
|
|
356
|
+
help: true,
|
|
357
|
+
outputJson: raw.outputJson,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
help: false,
|
|
363
|
+
mode: "places",
|
|
364
|
+
outputJson: raw.outputJson,
|
|
365
|
+
query: buildPlaceQuery(raw),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
284
369
|
function parseSetupArgs(args: string[]): ParsedArgs {
|
|
285
370
|
const outputJson = args.includes("--json");
|
|
286
371
|
const reset = args.includes("--reset");
|
|
@@ -409,6 +494,24 @@ function buildFlightBookingQuery(raw: FlightBookingRawOptions): FlightBookingQue
|
|
|
409
494
|
};
|
|
410
495
|
}
|
|
411
496
|
|
|
497
|
+
function buildPlaceQuery(raw: PlaceRawOptions): PlaceQuery {
|
|
498
|
+
if (!raw.near) {
|
|
499
|
+
throw new CliError("Missing required flag: --near", ExitCode.InvalidInput);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const near = normalizeLocation(raw.near);
|
|
503
|
+
const type = raw.type ? normalizePlaceType(raw.type) : "restaurant";
|
|
504
|
+
const limit = raw.limit ? normalizeLimit(raw.limit, "--limit") : 10;
|
|
505
|
+
const range = raw.range ? normalizePlaceRange(raw.range) : undefined;
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
near,
|
|
509
|
+
type,
|
|
510
|
+
limit,
|
|
511
|
+
range,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
412
515
|
function stripSubcommands(argv: string[]): { mode: SearchMode; args: string[] } {
|
|
413
516
|
const args = [...argv];
|
|
414
517
|
if (args[0] === "hotels") {
|
|
@@ -435,8 +538,13 @@ function stripSubcommands(argv: string[]): { mode: SearchMode; args: string[] }
|
|
|
435
538
|
return { mode: "setup", args };
|
|
436
539
|
}
|
|
437
540
|
|
|
541
|
+
if (args[0] === "places") {
|
|
542
|
+
args.shift();
|
|
543
|
+
return { mode: "places", args };
|
|
544
|
+
}
|
|
545
|
+
|
|
438
546
|
throw new CliError(
|
|
439
|
-
"Missing subcommand: use `setup`, `flights`, or `
|
|
547
|
+
"Missing subcommand: use `setup`, `flights`, `hotels`, or `places`",
|
|
440
548
|
ExitCode.InvalidInput,
|
|
441
549
|
);
|
|
442
550
|
}
|
|
@@ -521,6 +629,15 @@ function normalizeMaxPrice(value: string): number {
|
|
|
521
629
|
return numeric;
|
|
522
630
|
}
|
|
523
631
|
|
|
632
|
+
function normalizeLimit(value: string, flagName: string): number {
|
|
633
|
+
const numeric = Number.parseInt(value, 10);
|
|
634
|
+
if (!Number.isInteger(numeric) || numeric <= 0) {
|
|
635
|
+
throw new CliError(`${flagName} must be a positive integer`, ExitCode.InvalidInput);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
return numeric;
|
|
639
|
+
}
|
|
640
|
+
|
|
524
641
|
function normalizeAdults(value: string): number {
|
|
525
642
|
const numeric = Number.parseInt(value, 10);
|
|
526
643
|
if (!Number.isInteger(numeric) || numeric <= 0) {
|
|
@@ -539,6 +656,24 @@ function normalizeMinRating(value: string): 3.5 | 4 | 4.5 | 5 {
|
|
|
539
656
|
return numeric;
|
|
540
657
|
}
|
|
541
658
|
|
|
659
|
+
function normalizePlaceType(value: string): PlaceType {
|
|
660
|
+
const normalized = value.trim().toLowerCase();
|
|
661
|
+
if (normalized !== "restaurant" && normalized !== "coffee") {
|
|
662
|
+
throw new CliError("--type must be one of: restaurant, coffee", ExitCode.InvalidInput);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return normalized;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function normalizePlaceRange(value: string): PlaceRange {
|
|
669
|
+
const normalized = value.trim().toLowerCase();
|
|
670
|
+
if (normalized !== "walk") {
|
|
671
|
+
throw new CliError("--range must be: walk", ExitCode.InvalidInput);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return normalized;
|
|
675
|
+
}
|
|
676
|
+
|
|
542
677
|
function normalizeTime(value: string, flagName: string): number {
|
|
543
678
|
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value);
|
|
544
679
|
if (!match) {
|
package/src/serpapi.ts
CHANGED
|
@@ -8,6 +8,9 @@ import {
|
|
|
8
8
|
FlightSearchResult,
|
|
9
9
|
HotelOption,
|
|
10
10
|
HotelQuery,
|
|
11
|
+
PlaceOption,
|
|
12
|
+
PlaceQuery,
|
|
13
|
+
PlaceType,
|
|
11
14
|
} from "./types";
|
|
12
15
|
|
|
13
16
|
interface SerpApiAirport {
|
|
@@ -80,6 +83,23 @@ interface SerpApiHotelsResponse {
|
|
|
80
83
|
properties?: SerpApiHotelProperty[];
|
|
81
84
|
}
|
|
82
85
|
|
|
86
|
+
interface SerpApiLocalResult {
|
|
87
|
+
title?: string;
|
|
88
|
+
rating?: number;
|
|
89
|
+
reviews?: number;
|
|
90
|
+
address?: string;
|
|
91
|
+
distance?: string;
|
|
92
|
+
open_state?: string;
|
|
93
|
+
type?: string;
|
|
94
|
+
place_id_search?: string;
|
|
95
|
+
data_id?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface SerpApiPlacesResponse {
|
|
99
|
+
error?: string;
|
|
100
|
+
local_results?: SerpApiLocalResult[];
|
|
101
|
+
}
|
|
102
|
+
|
|
83
103
|
export async function searchFlights(
|
|
84
104
|
query: FlightQuery,
|
|
85
105
|
apiKey: string,
|
|
@@ -158,6 +178,25 @@ export async function searchHotels(
|
|
|
158
178
|
return hotels;
|
|
159
179
|
}
|
|
160
180
|
|
|
181
|
+
export async function searchPlaces(
|
|
182
|
+
query: PlaceQuery,
|
|
183
|
+
apiKey: string,
|
|
184
|
+
fetchImpl: typeof fetch = fetch,
|
|
185
|
+
): Promise<PlaceOption[]> {
|
|
186
|
+
const payload = await fetchSerpApiJson<SerpApiPlacesResponse>(
|
|
187
|
+
buildPlaceRequestUrl(query, apiKey),
|
|
188
|
+
fetchImpl,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
if (typeof payload.error === "string" && payload.error.trim() !== "") {
|
|
192
|
+
throw new CliError(`SerpApi error: ${payload.error}`, ExitCode.ApiFailure);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const places = shapePlaceSerpApiResponse(payload, query.type);
|
|
196
|
+
places.sort((a, b) => b.score - a.score);
|
|
197
|
+
return places.slice(0, query.limit);
|
|
198
|
+
}
|
|
199
|
+
|
|
161
200
|
export function shapeSerpApiResponse(payload: SerpApiFlightsResponse): FlightOption[] {
|
|
162
201
|
const merged = [...(payload.best_flights ?? []), ...(payload.other_flights ?? [])];
|
|
163
202
|
|
|
@@ -172,6 +211,15 @@ export function shapeHotelSerpApiResponse(payload: SerpApiHotelsResponse): Hotel
|
|
|
172
211
|
.filter((option): option is HotelOption => option !== null);
|
|
173
212
|
}
|
|
174
213
|
|
|
214
|
+
export function shapePlaceSerpApiResponse(
|
|
215
|
+
payload: SerpApiPlacesResponse,
|
|
216
|
+
category: PlaceType,
|
|
217
|
+
): PlaceOption[] {
|
|
218
|
+
return (payload.local_results ?? [])
|
|
219
|
+
.map((result) => shapeLocalResult(result, category))
|
|
220
|
+
.filter((option): option is PlaceOption => option !== null);
|
|
221
|
+
}
|
|
222
|
+
|
|
175
223
|
export function filterByDepartureWindow(
|
|
176
224
|
options: FlightOption[],
|
|
177
225
|
minMinutes: number,
|
|
@@ -336,6 +384,18 @@ function buildHotelRequestUrl(query: HotelQuery, apiKey: string): string {
|
|
|
336
384
|
return url.toString();
|
|
337
385
|
}
|
|
338
386
|
|
|
387
|
+
function buildPlaceRequestUrl(query: PlaceQuery, apiKey: string): string {
|
|
388
|
+
const url = new URL("https://serpapi.com/search.json");
|
|
389
|
+
const searchTerm = query.type === "coffee" ? "coffee" : "restaurants";
|
|
390
|
+
const rangePrefix = query.range === "walk" ? "within walking distance " : "";
|
|
391
|
+
|
|
392
|
+
url.searchParams.set("engine", "google_maps");
|
|
393
|
+
url.searchParams.set("type", "search");
|
|
394
|
+
url.searchParams.set("q", `${rangePrefix}${searchTerm} near ${query.near}`);
|
|
395
|
+
url.searchParams.set("api_key", apiKey);
|
|
396
|
+
return url.toString();
|
|
397
|
+
}
|
|
398
|
+
|
|
339
399
|
function buildFlightBookingOptionsRequestUrl(
|
|
340
400
|
query: FlightBookingQuery,
|
|
341
401
|
token: string,
|
|
@@ -383,6 +443,101 @@ function toSerpApiRating(rating: 3.5 | 4 | 4.5 | 5): string {
|
|
|
383
443
|
return "10";
|
|
384
444
|
}
|
|
385
445
|
|
|
446
|
+
function shapeLocalResult(result: SerpApiLocalResult, category: PlaceType): PlaceOption | null {
|
|
447
|
+
if (typeof result.title !== "string" || result.title.trim() === "") {
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const rating = Number.isFinite(result.rating) ? (result.rating as number) : undefined;
|
|
452
|
+
const reviews = Number.isFinite(result.reviews) ? (result.reviews as number) : undefined;
|
|
453
|
+
const distanceMeters = parseDistanceMeters(result.distance);
|
|
454
|
+
const link = buildGoogleMapsPlaceLink(result);
|
|
455
|
+
const googleMapsUrl = buildDirectGoogleMapsUrl(result);
|
|
456
|
+
|
|
457
|
+
return {
|
|
458
|
+
name: result.title.trim(),
|
|
459
|
+
category,
|
|
460
|
+
rating,
|
|
461
|
+
reviews,
|
|
462
|
+
address: typeof result.address === "string" ? result.address : undefined,
|
|
463
|
+
distanceMeters,
|
|
464
|
+
openState: typeof result.open_state === "string" ? result.open_state : undefined,
|
|
465
|
+
link,
|
|
466
|
+
googleMapsUrl,
|
|
467
|
+
score: computePlaceScore(rating, reviews, distanceMeters),
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function computePlaceScore(
|
|
472
|
+
rating: number | undefined,
|
|
473
|
+
reviews: number | undefined,
|
|
474
|
+
distanceMeters: number | undefined,
|
|
475
|
+
): number {
|
|
476
|
+
const ratingComponent = typeof rating === "number" ? rating / 5 : 0;
|
|
477
|
+
const reviewComponent =
|
|
478
|
+
typeof reviews === "number" && reviews > 0 ? Math.log10(reviews + 1) / 4 : 0;
|
|
479
|
+
const distanceComponent =
|
|
480
|
+
typeof distanceMeters === "number" && distanceMeters >= 0
|
|
481
|
+
? Math.max(0, 1 - distanceMeters / 10_000)
|
|
482
|
+
: 0;
|
|
483
|
+
|
|
484
|
+
return ratingComponent * 0.6 + reviewComponent * 0.25 + distanceComponent * 0.15;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function parseDistanceMeters(value?: string): number | undefined {
|
|
488
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
489
|
+
return undefined;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const normalized = value.trim().toLowerCase().replace(/,/g, "");
|
|
493
|
+
const match = /^(\d+(?:\.\d+)?)\s*(m|meter|meters|km|mi)$/.exec(normalized);
|
|
494
|
+
if (!match) {
|
|
495
|
+
return undefined;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const amount = Number(match[1]);
|
|
499
|
+
const unit = match[2];
|
|
500
|
+
if (!Number.isFinite(amount)) {
|
|
501
|
+
return undefined;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (unit === "m" || unit === "meter" || unit === "meters") {
|
|
505
|
+
return amount;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (unit === "km") {
|
|
509
|
+
return amount * 1000;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return amount * 1609.34;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function buildGoogleMapsPlaceLink(result: SerpApiLocalResult): string | undefined {
|
|
516
|
+
if (typeof result.place_id_search === "string" && result.place_id_search.trim() !== "") {
|
|
517
|
+
return result.place_id_search;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return undefined;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function buildDirectGoogleMapsUrl(result: SerpApiLocalResult): string | undefined {
|
|
524
|
+
if (typeof result.place_id_search !== "string" || result.place_id_search.trim() === "") {
|
|
525
|
+
return undefined;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
try {
|
|
529
|
+
const parsed = new URL(result.place_id_search);
|
|
530
|
+
const placeId = parsed.searchParams.get("place_id");
|
|
531
|
+
if (!placeId || placeId.trim() === "") {
|
|
532
|
+
return undefined;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return `https://www.google.com/maps/place/?q=place_id:${encodeURIComponent(placeId)}`;
|
|
536
|
+
} catch {
|
|
537
|
+
return undefined;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
386
541
|
async function fetchSerpApiJson<T>(
|
|
387
542
|
requestUrl: string,
|
|
388
543
|
fetchImpl: typeof fetch,
|
package/src/types.ts
CHANGED
|
@@ -30,6 +30,16 @@ export interface HotelQuery {
|
|
|
30
30
|
minRating?: 3.5 | 4 | 4.5 | 5;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export type PlaceType = "restaurant" | "coffee";
|
|
34
|
+
export type PlaceRange = "walk";
|
|
35
|
+
|
|
36
|
+
export interface PlaceQuery {
|
|
37
|
+
near: string;
|
|
38
|
+
type: PlaceType;
|
|
39
|
+
limit: number;
|
|
40
|
+
range?: PlaceRange;
|
|
41
|
+
}
|
|
42
|
+
|
|
33
43
|
export interface ParsedArgsHelp {
|
|
34
44
|
help: true;
|
|
35
45
|
outputJson: boolean;
|
|
@@ -70,10 +80,18 @@ export interface ParsedArgsSetup {
|
|
|
70
80
|
reset: boolean;
|
|
71
81
|
}
|
|
72
82
|
|
|
83
|
+
export interface ParsedArgsPlaces {
|
|
84
|
+
help: false;
|
|
85
|
+
mode: "places";
|
|
86
|
+
outputJson: boolean;
|
|
87
|
+
query: PlaceQuery;
|
|
88
|
+
}
|
|
89
|
+
|
|
73
90
|
export type ParsedArgs =
|
|
74
91
|
| ParsedArgsHelp
|
|
75
92
|
| ParsedArgsFlights
|
|
76
93
|
| ParsedArgsHotels
|
|
94
|
+
| ParsedArgsPlaces
|
|
77
95
|
| ParsedArgsFlightBooking
|
|
78
96
|
| ParsedArgsSetup;
|
|
79
97
|
|
|
@@ -97,6 +115,19 @@ export interface HotelOption {
|
|
|
97
115
|
link?: string;
|
|
98
116
|
}
|
|
99
117
|
|
|
118
|
+
export interface PlaceOption {
|
|
119
|
+
name: string;
|
|
120
|
+
category: PlaceType;
|
|
121
|
+
rating?: number;
|
|
122
|
+
reviews?: number;
|
|
123
|
+
address?: string;
|
|
124
|
+
distanceMeters?: number;
|
|
125
|
+
openState?: string;
|
|
126
|
+
link?: string;
|
|
127
|
+
googleMapsUrl?: string;
|
|
128
|
+
score: number;
|
|
129
|
+
}
|
|
130
|
+
|
|
100
131
|
export interface FlightSearchResult {
|
|
101
132
|
options: FlightOption[];
|
|
102
133
|
googleFlightsUrl?: string;
|