@tks/wayfinder 0.2.3 → 0.3.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 +24 -0
- package/package.json +1 -1
- package/src/cli.ts +34 -4
- package/src/format.ts +82 -1
- package/src/parse.ts +122 -3
- package/src/serpapi.ts +154 -0
- package/src/types.ts +29 -0
package/README.md
CHANGED
|
@@ -99,3 +99,27 @@ 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
|
+
Structured places output for scripting:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
wayfinder places --near "Shinjuku, Tokyo" --type coffee --json | jq '.results[] | {name,rating,reviews,googleMapsUrl}'
|
|
125
|
+
```
|
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.0 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] [--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,14 @@ 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
|
+
--limit <N> Maximum number of results (default 10)
|
|
76
|
+
|
|
68
77
|
Output:
|
|
69
78
|
--json Print structured JSON output`;
|
|
70
79
|
|
|
@@ -181,7 +190,7 @@ export async function runWayfinder(
|
|
|
181
190
|
} else {
|
|
182
191
|
output.stdout(renderFlightBookingText(flightLinks));
|
|
183
192
|
}
|
|
184
|
-
} else {
|
|
193
|
+
} else if (parsed.mode === "hotels") {
|
|
185
194
|
const hotels = await searchHotels(parsed.query, apiKey, options.fetchImpl ?? fetch);
|
|
186
195
|
|
|
187
196
|
if (hotels.length === 0) {
|
|
@@ -202,6 +211,27 @@ export async function runWayfinder(
|
|
|
202
211
|
} else {
|
|
203
212
|
output.stdout(renderHotelTable(hotels));
|
|
204
213
|
}
|
|
214
|
+
} else {
|
|
215
|
+
const places = await searchPlaces(parsed.query, apiKey, options.fetchImpl ?? fetch);
|
|
216
|
+
|
|
217
|
+
if (places.length === 0) {
|
|
218
|
+
throw new CliError("No places found for the selected query", ExitCode.NoResults);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (parsed.outputJson) {
|
|
222
|
+
output.stdout(
|
|
223
|
+
JSON.stringify(
|
|
224
|
+
{
|
|
225
|
+
query: parsed.query,
|
|
226
|
+
results: places,
|
|
227
|
+
},
|
|
228
|
+
null,
|
|
229
|
+
2,
|
|
230
|
+
),
|
|
231
|
+
);
|
|
232
|
+
} else {
|
|
233
|
+
output.stdout(renderPlaceTable(places));
|
|
234
|
+
}
|
|
205
235
|
}
|
|
206
236
|
|
|
207
237
|
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,13 @@
|
|
|
1
1
|
import { CliError } from "./errors";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
ExitCode,
|
|
4
|
+
FlightBookingQuery,
|
|
5
|
+
FlightQuery,
|
|
6
|
+
HotelQuery,
|
|
7
|
+
ParsedArgs,
|
|
8
|
+
PlaceQuery,
|
|
9
|
+
PlaceType,
|
|
10
|
+
} from "./types";
|
|
3
11
|
|
|
4
12
|
interface FlightRawOptions {
|
|
5
13
|
from?: string;
|
|
@@ -35,7 +43,15 @@ interface FlightBookingRawOptions {
|
|
|
35
43
|
help: boolean;
|
|
36
44
|
}
|
|
37
45
|
|
|
38
|
-
|
|
46
|
+
interface PlaceRawOptions {
|
|
47
|
+
near?: string;
|
|
48
|
+
type?: string;
|
|
49
|
+
limit?: string;
|
|
50
|
+
outputJson: boolean;
|
|
51
|
+
help: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type SearchMode = "flights" | "hotels" | "places" | "flight-booking" | "setup";
|
|
39
55
|
|
|
40
56
|
const HELP_FLAGS = new Set(["-h", "--help"]);
|
|
41
57
|
|
|
@@ -57,6 +73,10 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
|
|
|
57
73
|
return parseFlightBookingArgs(args);
|
|
58
74
|
}
|
|
59
75
|
|
|
76
|
+
if (mode === "places") {
|
|
77
|
+
return parsePlacesArgs(args);
|
|
78
|
+
}
|
|
79
|
+
|
|
60
80
|
if (mode === "setup") {
|
|
61
81
|
return parseSetupArgs(args);
|
|
62
82
|
}
|
|
@@ -281,6 +301,66 @@ function parseFlightBookingArgs(args: string[]): ParsedArgs {
|
|
|
281
301
|
};
|
|
282
302
|
}
|
|
283
303
|
|
|
304
|
+
function parsePlacesArgs(args: string[]): ParsedArgs {
|
|
305
|
+
const raw: PlaceRawOptions = {
|
|
306
|
+
outputJson: false,
|
|
307
|
+
help: false,
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
311
|
+
const token = args[i];
|
|
312
|
+
|
|
313
|
+
if (HELP_FLAGS.has(token)) {
|
|
314
|
+
raw.help = true;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (token === "--json") {
|
|
319
|
+
raw.outputJson = true;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (!token.startsWith("--")) {
|
|
324
|
+
throw new CliError(`Unexpected argument: ${token}`, ExitCode.InvalidInput);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const value = args[i + 1];
|
|
328
|
+
if (!value || value.startsWith("--")) {
|
|
329
|
+
throw new CliError(`Missing value for ${token}`, ExitCode.InvalidInput);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
switch (token) {
|
|
333
|
+
case "--near":
|
|
334
|
+
raw.near = value;
|
|
335
|
+
break;
|
|
336
|
+
case "--type":
|
|
337
|
+
raw.type = value;
|
|
338
|
+
break;
|
|
339
|
+
case "--limit":
|
|
340
|
+
raw.limit = value;
|
|
341
|
+
break;
|
|
342
|
+
default:
|
|
343
|
+
throw new CliError(`Unknown flag: ${token}`, ExitCode.InvalidInput);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
i += 1;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (raw.help) {
|
|
350
|
+
return {
|
|
351
|
+
help: true,
|
|
352
|
+
outputJson: raw.outputJson,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
help: false,
|
|
358
|
+
mode: "places",
|
|
359
|
+
outputJson: raw.outputJson,
|
|
360
|
+
query: buildPlaceQuery(raw),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
284
364
|
function parseSetupArgs(args: string[]): ParsedArgs {
|
|
285
365
|
const outputJson = args.includes("--json");
|
|
286
366
|
const reset = args.includes("--reset");
|
|
@@ -409,6 +489,22 @@ function buildFlightBookingQuery(raw: FlightBookingRawOptions): FlightBookingQue
|
|
|
409
489
|
};
|
|
410
490
|
}
|
|
411
491
|
|
|
492
|
+
function buildPlaceQuery(raw: PlaceRawOptions): PlaceQuery {
|
|
493
|
+
if (!raw.near) {
|
|
494
|
+
throw new CliError("Missing required flag: --near", ExitCode.InvalidInput);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const near = normalizeLocation(raw.near);
|
|
498
|
+
const type = raw.type ? normalizePlaceType(raw.type) : "restaurant";
|
|
499
|
+
const limit = raw.limit ? normalizeLimit(raw.limit, "--limit") : 10;
|
|
500
|
+
|
|
501
|
+
return {
|
|
502
|
+
near,
|
|
503
|
+
type,
|
|
504
|
+
limit,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
412
508
|
function stripSubcommands(argv: string[]): { mode: SearchMode; args: string[] } {
|
|
413
509
|
const args = [...argv];
|
|
414
510
|
if (args[0] === "hotels") {
|
|
@@ -435,8 +531,13 @@ function stripSubcommands(argv: string[]): { mode: SearchMode; args: string[] }
|
|
|
435
531
|
return { mode: "setup", args };
|
|
436
532
|
}
|
|
437
533
|
|
|
534
|
+
if (args[0] === "places") {
|
|
535
|
+
args.shift();
|
|
536
|
+
return { mode: "places", args };
|
|
537
|
+
}
|
|
538
|
+
|
|
438
539
|
throw new CliError(
|
|
439
|
-
"Missing subcommand: use `setup`, `flights`, or `
|
|
540
|
+
"Missing subcommand: use `setup`, `flights`, `hotels`, or `places`",
|
|
440
541
|
ExitCode.InvalidInput,
|
|
441
542
|
);
|
|
442
543
|
}
|
|
@@ -521,6 +622,15 @@ function normalizeMaxPrice(value: string): number {
|
|
|
521
622
|
return numeric;
|
|
522
623
|
}
|
|
523
624
|
|
|
625
|
+
function normalizeLimit(value: string, flagName: string): number {
|
|
626
|
+
const numeric = Number.parseInt(value, 10);
|
|
627
|
+
if (!Number.isInteger(numeric) || numeric <= 0) {
|
|
628
|
+
throw new CliError(`${flagName} must be a positive integer`, ExitCode.InvalidInput);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
return numeric;
|
|
632
|
+
}
|
|
633
|
+
|
|
524
634
|
function normalizeAdults(value: string): number {
|
|
525
635
|
const numeric = Number.parseInt(value, 10);
|
|
526
636
|
if (!Number.isInteger(numeric) || numeric <= 0) {
|
|
@@ -539,6 +649,15 @@ function normalizeMinRating(value: string): 3.5 | 4 | 4.5 | 5 {
|
|
|
539
649
|
return numeric;
|
|
540
650
|
}
|
|
541
651
|
|
|
652
|
+
function normalizePlaceType(value: string): PlaceType {
|
|
653
|
+
const normalized = value.trim().toLowerCase();
|
|
654
|
+
if (normalized !== "restaurant" && normalized !== "coffee") {
|
|
655
|
+
throw new CliError("--type must be one of: restaurant, coffee", ExitCode.InvalidInput);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return normalized;
|
|
659
|
+
}
|
|
660
|
+
|
|
542
661
|
function normalizeTime(value: string, flagName: string): number {
|
|
543
662
|
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value);
|
|
544
663
|
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,17 @@ 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
|
+
|
|
391
|
+
url.searchParams.set("engine", "google_maps");
|
|
392
|
+
url.searchParams.set("type", "search");
|
|
393
|
+
url.searchParams.set("q", `${searchTerm} near ${query.near}`);
|
|
394
|
+
url.searchParams.set("api_key", apiKey);
|
|
395
|
+
return url.toString();
|
|
396
|
+
}
|
|
397
|
+
|
|
339
398
|
function buildFlightBookingOptionsRequestUrl(
|
|
340
399
|
query: FlightBookingQuery,
|
|
341
400
|
token: string,
|
|
@@ -383,6 +442,101 @@ function toSerpApiRating(rating: 3.5 | 4 | 4.5 | 5): string {
|
|
|
383
442
|
return "10";
|
|
384
443
|
}
|
|
385
444
|
|
|
445
|
+
function shapeLocalResult(result: SerpApiLocalResult, category: PlaceType): PlaceOption | null {
|
|
446
|
+
if (typeof result.title !== "string" || result.title.trim() === "") {
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const rating = Number.isFinite(result.rating) ? (result.rating as number) : undefined;
|
|
451
|
+
const reviews = Number.isFinite(result.reviews) ? (result.reviews as number) : undefined;
|
|
452
|
+
const distanceMeters = parseDistanceMeters(result.distance);
|
|
453
|
+
const link = buildGoogleMapsPlaceLink(result);
|
|
454
|
+
const googleMapsUrl = buildDirectGoogleMapsUrl(result);
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
name: result.title.trim(),
|
|
458
|
+
category,
|
|
459
|
+
rating,
|
|
460
|
+
reviews,
|
|
461
|
+
address: typeof result.address === "string" ? result.address : undefined,
|
|
462
|
+
distanceMeters,
|
|
463
|
+
openState: typeof result.open_state === "string" ? result.open_state : undefined,
|
|
464
|
+
link,
|
|
465
|
+
googleMapsUrl,
|
|
466
|
+
score: computePlaceScore(rating, reviews, distanceMeters),
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function computePlaceScore(
|
|
471
|
+
rating: number | undefined,
|
|
472
|
+
reviews: number | undefined,
|
|
473
|
+
distanceMeters: number | undefined,
|
|
474
|
+
): number {
|
|
475
|
+
const ratingComponent = typeof rating === "number" ? rating / 5 : 0;
|
|
476
|
+
const reviewComponent =
|
|
477
|
+
typeof reviews === "number" && reviews > 0 ? Math.log10(reviews + 1) / 4 : 0;
|
|
478
|
+
const distanceComponent =
|
|
479
|
+
typeof distanceMeters === "number" && distanceMeters >= 0
|
|
480
|
+
? Math.max(0, 1 - distanceMeters / 10_000)
|
|
481
|
+
: 0;
|
|
482
|
+
|
|
483
|
+
return ratingComponent * 0.6 + reviewComponent * 0.25 + distanceComponent * 0.15;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function parseDistanceMeters(value?: string): number | undefined {
|
|
487
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
488
|
+
return undefined;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const normalized = value.trim().toLowerCase().replace(/,/g, "");
|
|
492
|
+
const match = /^(\d+(?:\.\d+)?)\s*(m|meter|meters|km|mi)$/.exec(normalized);
|
|
493
|
+
if (!match) {
|
|
494
|
+
return undefined;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const amount = Number(match[1]);
|
|
498
|
+
const unit = match[2];
|
|
499
|
+
if (!Number.isFinite(amount)) {
|
|
500
|
+
return undefined;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (unit === "m" || unit === "meter" || unit === "meters") {
|
|
504
|
+
return amount;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (unit === "km") {
|
|
508
|
+
return amount * 1000;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return amount * 1609.34;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function buildGoogleMapsPlaceLink(result: SerpApiLocalResult): string | undefined {
|
|
515
|
+
if (typeof result.place_id_search === "string" && result.place_id_search.trim() !== "") {
|
|
516
|
+
return result.place_id_search;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return undefined;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function buildDirectGoogleMapsUrl(result: SerpApiLocalResult): string | undefined {
|
|
523
|
+
if (typeof result.place_id_search !== "string" || result.place_id_search.trim() === "") {
|
|
524
|
+
return undefined;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
try {
|
|
528
|
+
const parsed = new URL(result.place_id_search);
|
|
529
|
+
const placeId = parsed.searchParams.get("place_id");
|
|
530
|
+
if (!placeId || placeId.trim() === "") {
|
|
531
|
+
return undefined;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return `https://www.google.com/maps/place/?q=place_id:${encodeURIComponent(placeId)}`;
|
|
535
|
+
} catch {
|
|
536
|
+
return undefined;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
386
540
|
async function fetchSerpApiJson<T>(
|
|
387
541
|
requestUrl: string,
|
|
388
542
|
fetchImpl: typeof fetch,
|
package/src/types.ts
CHANGED
|
@@ -30,6 +30,14 @@ export interface HotelQuery {
|
|
|
30
30
|
minRating?: 3.5 | 4 | 4.5 | 5;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export type PlaceType = "restaurant" | "coffee";
|
|
34
|
+
|
|
35
|
+
export interface PlaceQuery {
|
|
36
|
+
near: string;
|
|
37
|
+
type: PlaceType;
|
|
38
|
+
limit: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
33
41
|
export interface ParsedArgsHelp {
|
|
34
42
|
help: true;
|
|
35
43
|
outputJson: boolean;
|
|
@@ -70,10 +78,18 @@ export interface ParsedArgsSetup {
|
|
|
70
78
|
reset: boolean;
|
|
71
79
|
}
|
|
72
80
|
|
|
81
|
+
export interface ParsedArgsPlaces {
|
|
82
|
+
help: false;
|
|
83
|
+
mode: "places";
|
|
84
|
+
outputJson: boolean;
|
|
85
|
+
query: PlaceQuery;
|
|
86
|
+
}
|
|
87
|
+
|
|
73
88
|
export type ParsedArgs =
|
|
74
89
|
| ParsedArgsHelp
|
|
75
90
|
| ParsedArgsFlights
|
|
76
91
|
| ParsedArgsHotels
|
|
92
|
+
| ParsedArgsPlaces
|
|
77
93
|
| ParsedArgsFlightBooking
|
|
78
94
|
| ParsedArgsSetup;
|
|
79
95
|
|
|
@@ -97,6 +113,19 @@ export interface HotelOption {
|
|
|
97
113
|
link?: string;
|
|
98
114
|
}
|
|
99
115
|
|
|
116
|
+
export interface PlaceOption {
|
|
117
|
+
name: string;
|
|
118
|
+
category: PlaceType;
|
|
119
|
+
rating?: number;
|
|
120
|
+
reviews?: number;
|
|
121
|
+
address?: string;
|
|
122
|
+
distanceMeters?: number;
|
|
123
|
+
openState?: string;
|
|
124
|
+
link?: string;
|
|
125
|
+
googleMapsUrl?: string;
|
|
126
|
+
score: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
100
129
|
export interface FlightSearchResult {
|
|
101
130
|
options: FlightOption[];
|
|
102
131
|
googleFlightsUrl?: string;
|