mcp-google-api 0.21.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.
@@ -0,0 +1,1860 @@
1
+ // src/services/toolclass.ts
2
+ import { Client, Language } from "@googlemaps/google-maps-services-js";
3
+ import dotenv from "dotenv";
4
+
5
+ // src/services/RoutesService.ts
6
+ var ROUTES_API_BASE = "https://routes.googleapis.com";
7
+ var TRAVEL_MODE_MAP = {
8
+ driving: "DRIVE",
9
+ walking: "WALK",
10
+ bicycling: "BICYCLE",
11
+ transit: "TRANSIT"
12
+ };
13
+ var COMPUTE_ROUTES_FIELD_MASK = [
14
+ "routes.distanceMeters",
15
+ "routes.duration",
16
+ "routes.description",
17
+ "routes.polyline.encodedPolyline",
18
+ "routes.legs.distanceMeters",
19
+ "routes.legs.duration",
20
+ "routes.legs.startLocation",
21
+ "routes.legs.endLocation",
22
+ "routes.legs.steps.navigationInstruction",
23
+ "routes.legs.steps.distanceMeters",
24
+ "routes.legs.steps.staticDuration",
25
+ "routes.legs.steps.startLocation",
26
+ "routes.legs.steps.endLocation",
27
+ "routes.legs.steps.transitDetails",
28
+ "routes.legs.polyline",
29
+ "routes.optimizedIntermediateWaypointIndex"
30
+ ].join(",");
31
+ var COMPUTE_ROUTE_MATRIX_FIELD_MASK = "originIndex,destinationIndex,distanceMeters,duration,status,condition";
32
+ function parseDuration(duration) {
33
+ if (!duration) return 0;
34
+ const match = duration.match(/^(\d+)s$/);
35
+ return match ? parseInt(match[1], 10) : 0;
36
+ }
37
+ function formatDistance(meters) {
38
+ if (meters >= 1e3) {
39
+ return `${(meters / 1e3).toFixed(1)} km`;
40
+ }
41
+ return `${meters} m`;
42
+ }
43
+ function formatDuration(seconds) {
44
+ if (seconds >= 3600) {
45
+ const hours = Math.floor(seconds / 3600);
46
+ const mins2 = Math.round(seconds % 3600 / 60);
47
+ return mins2 > 0 ? `${hours} hour${hours > 1 ? "s" : ""} ${mins2} min${mins2 > 1 ? "s" : ""}` : `${hours} hour${hours > 1 ? "s" : ""}`;
48
+ }
49
+ const mins = Math.round(seconds / 60);
50
+ return `${mins} min${mins !== 1 ? "s" : ""}`;
51
+ }
52
+ var COORDINATE_STRING_PATTERN = /^\s*(-?\d+\.?\d*)\s*,\s*(-?\d+\.?\d*)\s*$/;
53
+ function describeWaypoint(waypoint) {
54
+ if (typeof waypoint === "string") {
55
+ return waypoint;
56
+ }
57
+ if ("placeId" in waypoint) {
58
+ return `placeId:${waypoint.placeId}`;
59
+ }
60
+ return `${waypoint.latLng.latitude},${waypoint.latLng.longitude}`;
61
+ }
62
+ function toWaypoint(location) {
63
+ if (typeof location !== "string") {
64
+ return "placeId" in location ? { placeId: location.placeId } : { location: { latLng: location.latLng } };
65
+ }
66
+ const coordMatch = location.match(COORDINATE_STRING_PATTERN);
67
+ if (coordMatch) {
68
+ return {
69
+ location: {
70
+ latLng: {
71
+ latitude: parseFloat(coordMatch[1]),
72
+ longitude: parseFloat(coordMatch[2])
73
+ }
74
+ }
75
+ };
76
+ }
77
+ return { address: location };
78
+ }
79
+ function buildRouteModifiers(params, travelMode) {
80
+ const routeModifiers = {};
81
+ if (params.avoidTolls) {
82
+ routeModifiers.avoidTolls = true;
83
+ }
84
+ if (params.avoidHighways) {
85
+ routeModifiers.avoidHighways = true;
86
+ }
87
+ if (Object.keys(routeModifiers).length === 0) {
88
+ return void 0;
89
+ }
90
+ if (travelMode !== "DRIVE") {
91
+ throw new Error('Route modifiers "avoid_tolls" and "avoid_highways" are only supported with mode "driving".');
92
+ }
93
+ return routeModifiers;
94
+ }
95
+ var RoutesService = class {
96
+ constructor(apiKey) {
97
+ this.apiKey = apiKey || "";
98
+ if (!this.apiKey) {
99
+ throw new Error("Google Maps API Key is required");
100
+ }
101
+ }
102
+ /**
103
+ * Compute routes using Routes API.
104
+ * Returns response compatible with existing DirectionsResponse.data interface.
105
+ */
106
+ async computeRoutes(params) {
107
+ const travelMode = TRAVEL_MODE_MAP[params.mode || "driving"] || "DRIVE";
108
+ const requestBody = {
109
+ origin: toWaypoint(params.origin),
110
+ destination: toWaypoint(params.destination),
111
+ travelMode,
112
+ computeAlternativeRoutes: false
113
+ };
114
+ if (travelMode === "DRIVE") {
115
+ requestBody.routingPreference = "TRAFFIC_AWARE";
116
+ }
117
+ const routeModifiers = buildRouteModifiers(params, travelMode);
118
+ if (routeModifiers) {
119
+ requestBody.routeModifiers = routeModifiers;
120
+ }
121
+ if (params.arrivalTime) {
122
+ requestBody.arrivalTime = params.arrivalTime.toISOString();
123
+ } else if (params.departureTime) {
124
+ requestBody.departureTime = params.departureTime.toISOString();
125
+ }
126
+ if (params.intermediates && params.intermediates.length > 0) {
127
+ requestBody.intermediates = params.intermediates.map(toWaypoint);
128
+ }
129
+ if (params.optimizeWaypointOrder && params.intermediates && params.intermediates.length > 1 && travelMode !== "TRANSIT") {
130
+ requestBody.optimizeWaypointOrder = true;
131
+ }
132
+ const response = await fetch(`${ROUTES_API_BASE}/directions/v2:computeRoutes`, {
133
+ method: "POST",
134
+ headers: {
135
+ "Content-Type": "application/json",
136
+ "X-Goog-Api-Key": this.apiKey,
137
+ "X-Goog-FieldMask": COMPUTE_ROUTES_FIELD_MASK
138
+ },
139
+ body: JSON.stringify(requestBody)
140
+ });
141
+ if (!response.ok) {
142
+ const errorData = await response.json().catch(() => ({}));
143
+ const msg = errorData?.error?.message || `HTTP ${response.status}`;
144
+ throw new Error(msg);
145
+ }
146
+ const data = await response.json();
147
+ if (!data.routes || data.routes.length === 0) {
148
+ const mode = params.mode || "driving";
149
+ if (mode === "transit") {
150
+ throw new Error(
151
+ `No transit route found from "${params.originLabel ?? describeWaypoint(params.origin)}" to "${params.destinationLabel ?? describeWaypoint(params.destination)}". The Google Routes API does not support transit directions in some regions (notably Japan and India). Try using mode "driving" or "walking" instead, or use a regional transit service for public transportation details.`
152
+ );
153
+ }
154
+ throw new Error(
155
+ `No route found from "${params.originLabel ?? describeWaypoint(params.origin)}" to "${params.destinationLabel ?? describeWaypoint(params.destination)}" with mode: ${mode}`
156
+ );
157
+ }
158
+ const route = data.routes[0];
159
+ const totalDistanceMeters = route.distanceMeters || 0;
160
+ const totalDurationSeconds = parseDuration(route.duration);
161
+ return {
162
+ routes: data.routes,
163
+ summary: route.description || "",
164
+ total_distance: {
165
+ value: totalDistanceMeters,
166
+ text: formatDistance(totalDistanceMeters)
167
+ },
168
+ total_duration: {
169
+ value: totalDurationSeconds,
170
+ text: formatDuration(totalDurationSeconds)
171
+ },
172
+ arrival_time: "",
173
+ departure_time: "",
174
+ ...route.optimizedIntermediateWaypointIndex ? { optimizedIntermediateWaypointIndex: route.optimizedIntermediateWaypointIndex } : {}
175
+ };
176
+ }
177
+ /**
178
+ * Compute route matrix using Routes API.
179
+ * Returns response compatible with existing DistanceMatrixResponse.data interface.
180
+ */
181
+ async computeRouteMatrix(params) {
182
+ const travelMode = TRAVEL_MODE_MAP[params.mode || "driving"] || "DRIVE";
183
+ const requestBody = {
184
+ origins: params.origins.map((o) => ({ waypoint: toWaypoint(o) })),
185
+ destinations: params.destinations.map((d) => ({ waypoint: toWaypoint(d) })),
186
+ travelMode
187
+ };
188
+ if (travelMode === "DRIVE") {
189
+ requestBody.routingPreference = "TRAFFIC_AWARE";
190
+ }
191
+ const routeModifiers = buildRouteModifiers(params, travelMode);
192
+ if (routeModifiers) {
193
+ requestBody.routeModifiers = routeModifiers;
194
+ }
195
+ if (params.departureTime) {
196
+ requestBody.departureTime = params.departureTime.toISOString();
197
+ }
198
+ const response = await fetch(`${ROUTES_API_BASE}/distanceMatrix/v2:computeRouteMatrix`, {
199
+ method: "POST",
200
+ headers: {
201
+ "Content-Type": "application/json",
202
+ "X-Goog-Api-Key": this.apiKey,
203
+ "X-Goog-FieldMask": COMPUTE_ROUTE_MATRIX_FIELD_MASK
204
+ },
205
+ body: JSON.stringify(requestBody)
206
+ });
207
+ if (!response.ok) {
208
+ const errorData = await response.json().catch(() => ({}));
209
+ const msg = errorData?.error?.message || `HTTP ${response.status}`;
210
+ throw new Error(msg);
211
+ }
212
+ const elements = await response.json();
213
+ const rowCount = params.origins.length;
214
+ const colCount = params.destinations.length;
215
+ const distances = Array.from({ length: rowCount }, () => Array(colCount).fill(null));
216
+ const durations = Array.from({ length: rowCount }, () => Array(colCount).fill(null));
217
+ let routeNotFoundCount = 0;
218
+ for (const element of elements) {
219
+ const i = element.originIndex;
220
+ const j = element.destinationIndex;
221
+ if (i === void 0 || j === void 0) continue;
222
+ if (element.condition === "ROUTE_NOT_FOUND") {
223
+ routeNotFoundCount++;
224
+ continue;
225
+ }
226
+ const distMeters = element.distanceMeters || 0;
227
+ const durSeconds = parseDuration(element.duration);
228
+ distances[i][j] = {
229
+ value: distMeters,
230
+ text: formatDistance(distMeters)
231
+ };
232
+ durations[i][j] = {
233
+ value: durSeconds,
234
+ text: formatDuration(durSeconds)
235
+ };
236
+ }
237
+ const totalPairs = rowCount * colCount;
238
+ if (routeNotFoundCount === totalPairs && travelMode === "TRANSIT") {
239
+ throw new Error(
240
+ `No transit routes found for any origin/destination pair. The Google Routes API does not support transit directions in some regions (notably Japan and India). Try using mode "driving" or "walking" instead, or use a regional transit service for public transportation details.`
241
+ );
242
+ }
243
+ return {
244
+ distances,
245
+ durations,
246
+ origin_addresses: params.origins,
247
+ destination_addresses: params.destinations,
248
+ ...routeNotFoundCount > 0 && travelMode === "TRANSIT" ? {
249
+ warning: `${routeNotFoundCount} of ${totalPairs} origin/destination pairs returned no transit route. The Google Routes API has limited transit coverage in some regions.`
250
+ } : {}
251
+ };
252
+ }
253
+ };
254
+
255
+ // src/services/toolclass.ts
256
+ dotenv.config();
257
+ function extractErrorMessage(error) {
258
+ const statusCode = error?.response?.status;
259
+ const apiError = error?.response?.data?.error_message;
260
+ const apiStatus = error?.response?.data?.status;
261
+ if (statusCode === 403) {
262
+ return "API key invalid or required API not enabled. Check: console.cloud.google.com \u2192 APIs & Services \u2192 Enable the relevant API (Places, Geocoding, etc.)";
263
+ }
264
+ if (statusCode === 429) {
265
+ return "API quota exceeded. Wait and retry, or check quota at console.cloud.google.com \u2192 Quotas";
266
+ }
267
+ if (apiStatus === "ZERO_RESULTS") {
268
+ return "No results found. Try broader search terms or a larger radius.";
269
+ }
270
+ if (apiStatus === "OVER_QUERY_LIMIT") {
271
+ return "API quota exceeded. Wait and retry, or upgrade your billing plan.";
272
+ }
273
+ if (apiStatus === "REQUEST_DENIED") {
274
+ return `Request denied by Google Maps API. ${apiError || "Check your API key and enabled APIs."}`;
275
+ }
276
+ if (apiStatus === "INVALID_REQUEST") {
277
+ return `Invalid request parameters. ${apiError || "Check your input values."}`;
278
+ }
279
+ if (apiError) {
280
+ return `${apiError} (HTTP ${statusCode})`;
281
+ }
282
+ return error instanceof Error ? error.message : String(error);
283
+ }
284
+ var GoogleMapsTools = class {
285
+ constructor(apiKey) {
286
+ this.defaultLanguage = Language.en;
287
+ this.client = new Client({});
288
+ this.apiKey = apiKey || "";
289
+ if (!this.apiKey) {
290
+ throw new Error("Google Maps API Key is required");
291
+ }
292
+ }
293
+ async geocodeAddress(address) {
294
+ try {
295
+ const response = await this.client.geocode({
296
+ params: {
297
+ address,
298
+ key: this.apiKey,
299
+ language: this.defaultLanguage
300
+ }
301
+ });
302
+ if (response.data.results.length === 0) {
303
+ throw new Error(`No location found for address: "${address}"`);
304
+ }
305
+ const result = response.data.results[0];
306
+ const location = result.geometry.location;
307
+ return {
308
+ lat: location.lat,
309
+ lng: location.lng,
310
+ formatted_address: result.formatted_address,
311
+ place_id: result.place_id
312
+ };
313
+ } catch (error) {
314
+ Logger.error("Error in geocodeAddress:", error);
315
+ throw new Error(`Failed to geocode address "${address}": ${extractErrorMessage(error)}`);
316
+ }
317
+ }
318
+ parseCoordinates(coordString) {
319
+ const coords = coordString.split(",").map((c) => parseFloat(c.trim()));
320
+ if (coords.length !== 2 || isNaN(coords[0]) || isNaN(coords[1])) {
321
+ throw new Error(
322
+ `Invalid coordinate format: "${coordString}". Please use "latitude,longitude" format (e.g., "25.033,121.564"`
323
+ );
324
+ }
325
+ return { lat: coords[0], lng: coords[1] };
326
+ }
327
+ async getLocation(center) {
328
+ if (center.isCoordinates) {
329
+ return this.parseCoordinates(center.value);
330
+ }
331
+ return this.geocodeAddress(center.value);
332
+ }
333
+ async geocode(address) {
334
+ try {
335
+ const result = await this.geocodeAddress(address);
336
+ return {
337
+ location: { lat: result.lat, lng: result.lng },
338
+ formatted_address: result.formatted_address || "",
339
+ place_id: result.place_id || ""
340
+ };
341
+ } catch (error) {
342
+ Logger.error("Error in geocode:", error);
343
+ throw new Error(`Failed to geocode address "${address}": ${extractErrorMessage(error)}`);
344
+ }
345
+ }
346
+ async reverseGeocode(latitude, longitude) {
347
+ try {
348
+ const response = await this.client.reverseGeocode({
349
+ params: {
350
+ latlng: { lat: latitude, lng: longitude },
351
+ language: this.defaultLanguage,
352
+ key: this.apiKey
353
+ }
354
+ });
355
+ if (response.data.results.length === 0) {
356
+ throw new Error(`No address found for coordinates: (${latitude}, ${longitude})`);
357
+ }
358
+ const result = response.data.results[0];
359
+ return {
360
+ formatted_address: result.formatted_address,
361
+ place_id: result.place_id,
362
+ address_components: result.address_components
363
+ };
364
+ } catch (error) {
365
+ Logger.error("Error in reverseGeocode:", error);
366
+ throw new Error(
367
+ `Failed to reverse geocode coordinates (${latitude}, ${longitude}): ${extractErrorMessage(error)}`
368
+ );
369
+ }
370
+ }
371
+ async searchAlongRoute(params) {
372
+ try {
373
+ const routesService = new RoutesService(this.apiKey);
374
+ const directions = await routesService.computeRoutes({
375
+ origin: params.origin,
376
+ destination: params.destination,
377
+ mode: params.mode || "walking"
378
+ });
379
+ const polyline = directions.routes[0]?.polyline?.encodedPolyline;
380
+ if (!polyline) {
381
+ throw new Error("Could not get route polyline");
382
+ }
383
+ const maxResults = Math.min(params.maxResults || 5, 20);
384
+ const fieldMask = "places.displayName,places.id,places.formattedAddress,places.location,places.rating,places.userRatingCount,places.currentOpeningHours.openNow";
385
+ const response = await fetch("https://places.googleapis.com/v1/places:searchText", {
386
+ method: "POST",
387
+ headers: {
388
+ "Content-Type": "application/json",
389
+ "X-Goog-Api-Key": this.apiKey,
390
+ "X-Goog-FieldMask": fieldMask
391
+ },
392
+ body: JSON.stringify({
393
+ textQuery: params.textQuery,
394
+ searchAlongRouteParameters: {
395
+ polyline: {
396
+ encodedPolyline: polyline
397
+ }
398
+ },
399
+ maxResultCount: maxResults
400
+ })
401
+ });
402
+ if (!response.ok) {
403
+ const errorData = await response.json().catch(() => ({}));
404
+ throw new Error(errorData?.error?.message || `HTTP ${response.status}`);
405
+ }
406
+ const data = await response.json();
407
+ const places = (data.places || []).map((place) => ({
408
+ name: place.displayName?.text || "",
409
+ place_id: place.id || "",
410
+ formatted_address: place.formattedAddress || "",
411
+ location: {
412
+ lat: place.location?.latitude || 0,
413
+ lng: place.location?.longitude || 0
414
+ },
415
+ rating: place.rating || 0,
416
+ user_ratings_total: place.userRatingCount || 0,
417
+ open_now: place.currentOpeningHours?.openNow ?? null
418
+ }));
419
+ return {
420
+ places,
421
+ route: {
422
+ distance: directions.total_distance.text,
423
+ duration: directions.total_duration.text,
424
+ polyline
425
+ }
426
+ };
427
+ } catch (error) {
428
+ Logger.error("Error in searchAlongRoute:", error);
429
+ throw new Error(error.message || "Failed to search along route");
430
+ }
431
+ }
432
+ async getWeather(latitude, longitude, type = "current", forecastDays, forecastHours) {
433
+ try {
434
+ const baseParams = `key=${this.apiKey}&location.latitude=${latitude}&location.longitude=${longitude}`;
435
+ let url;
436
+ switch (type) {
437
+ case "forecast_daily": {
438
+ const days = Math.min(Math.max(forecastDays || 5, 1), 10);
439
+ url = `https://weather.googleapis.com/v1/forecast/days:lookup?${baseParams}&days=${days}`;
440
+ break;
441
+ }
442
+ case "forecast_hourly": {
443
+ const hours = Math.min(Math.max(forecastHours || 24, 1), 240);
444
+ url = `https://weather.googleapis.com/v1/forecast/hours:lookup?${baseParams}&hours=${hours}`;
445
+ break;
446
+ }
447
+ default:
448
+ url = `https://weather.googleapis.com/v1/currentConditions:lookup?${baseParams}`;
449
+ }
450
+ const response = await fetch(url);
451
+ if (!response.ok) {
452
+ const errorData = await response.json().catch(() => ({}));
453
+ const msg = errorData?.error?.message || `HTTP ${response.status}`;
454
+ if (msg.includes("not supported for this location")) {
455
+ throw new Error(
456
+ `Weather data is not available for this location (${latitude}, ${longitude}). The Google Weather API has limited coverage \u2014 China, Japan, South Korea, Cuba, Iran, North Korea, and Syria are unsupported. Try a location in North America, Europe, or Oceania.`
457
+ );
458
+ }
459
+ throw new Error(msg);
460
+ }
461
+ const data = await response.json();
462
+ if (type === "current") {
463
+ return {
464
+ temperature: data.temperature,
465
+ feelsLike: data.feelsLikeTemperature,
466
+ humidity: data.relativeHumidity,
467
+ wind: data.wind,
468
+ conditions: data.weatherCondition?.description?.text || data.weatherCondition?.type,
469
+ uvIndex: data.uvIndex,
470
+ precipitation: data.precipitation,
471
+ visibility: data.visibility,
472
+ pressure: data.airPressure,
473
+ cloudCover: data.cloudCover,
474
+ isDayTime: data.isDaytime
475
+ };
476
+ }
477
+ return data;
478
+ } catch (error) {
479
+ Logger.error("Error in getWeather:", error);
480
+ throw new Error(error.message || `Failed to get weather for (${latitude}, ${longitude})`);
481
+ }
482
+ }
483
+ async getAirQuality(latitude, longitude, includeHealthRecommendations = true, includePollutants = false) {
484
+ try {
485
+ const url = `https://airquality.googleapis.com/v1/currentConditions:lookup?key=${this.apiKey}`;
486
+ const extraComputations = [];
487
+ if (includeHealthRecommendations) {
488
+ extraComputations.push("HEALTH_RECOMMENDATIONS");
489
+ }
490
+ if (includePollutants) {
491
+ extraComputations.push("POLLUTANT_CONCENTRATION");
492
+ }
493
+ const body = {
494
+ location: { latitude, longitude }
495
+ };
496
+ if (extraComputations.length > 0) {
497
+ body.extraComputations = extraComputations;
498
+ }
499
+ const response = await fetch(url, {
500
+ method: "POST",
501
+ headers: { "Content-Type": "application/json" },
502
+ body: JSON.stringify(body)
503
+ });
504
+ if (!response.ok) {
505
+ const errorData = await response.json().catch(() => ({}));
506
+ const msg = errorData?.error?.message || `HTTP ${response.status}`;
507
+ throw new Error(msg);
508
+ }
509
+ const data = await response.json();
510
+ const indexes = data.indexes || [];
511
+ const primaryIndex = indexes[0];
512
+ const result = {
513
+ dateTime: data.dateTime,
514
+ regionCode: data.regionCode,
515
+ aqi: primaryIndex?.aqi,
516
+ category: primaryIndex?.category,
517
+ dominantPollutant: primaryIndex?.dominantPollutant,
518
+ color: primaryIndex?.color
519
+ };
520
+ if (indexes.length > 1) {
521
+ result.indexes = indexes.map((idx) => ({
522
+ code: idx.code,
523
+ displayName: idx.displayName,
524
+ aqi: idx.aqi,
525
+ category: idx.category,
526
+ dominantPollutant: idx.dominantPollutant
527
+ }));
528
+ }
529
+ if (data.healthRecommendations) {
530
+ result.healthRecommendations = data.healthRecommendations;
531
+ }
532
+ if (data.pollutants) {
533
+ result.pollutants = data.pollutants.map((p) => ({
534
+ code: p.code,
535
+ displayName: p.displayName,
536
+ concentration: p.concentration,
537
+ additionalInfo: p.additionalInfo
538
+ }));
539
+ }
540
+ return result;
541
+ } catch (error) {
542
+ Logger.error("Error in getAirQuality:", error);
543
+ throw new Error(error.message || `Failed to get air quality for (${latitude}, ${longitude})`);
544
+ }
545
+ }
546
+ async getStaticMap(params) {
547
+ try {
548
+ const dimensions = params.size || "600x400";
549
+ const queryParts = [
550
+ `key=${this.apiKey}`,
551
+ `size=${dimensions}`,
552
+ `maptype=${params.maptype || "roadmap"}`
553
+ ];
554
+ if (params.center) {
555
+ queryParts.push(`center=${encodeURIComponent(params.center)}`);
556
+ }
557
+ if (params.zoom !== void 0) {
558
+ queryParts.push(`zoom=${params.zoom}`);
559
+ }
560
+ if (params.markers) {
561
+ for (const marker of params.markers) {
562
+ queryParts.push(`markers=${encodeURIComponent(marker)}`);
563
+ }
564
+ }
565
+ if (params.path) {
566
+ for (const p of params.path) {
567
+ queryParts.push(`path=${encodeURIComponent(p)}`);
568
+ }
569
+ }
570
+ const url = `https://maps.googleapis.com/maps/api/staticmap?${queryParts.join("&")}`;
571
+ if (url.length > 16384) {
572
+ throw new Error(`URL exceeds 16,384 character limit (${url.length}). Reduce markers or path points.`);
573
+ }
574
+ const response = await fetch(url);
575
+ if (!response.ok) {
576
+ const contentType = response.headers.get("content-type") || "";
577
+ if (contentType.includes("application/json") || contentType.includes("text/")) {
578
+ const errorText = await response.text();
579
+ throw new Error(`Static Maps API error: ${errorText}`);
580
+ }
581
+ throw new Error(`Static Maps API returned HTTP ${response.status}`);
582
+ }
583
+ const arrayBuffer = await response.arrayBuffer();
584
+ const buffer = Buffer.from(arrayBuffer);
585
+ const base64 = buffer.toString("base64");
586
+ return {
587
+ base64,
588
+ size: buffer.length,
589
+ dimensions
590
+ };
591
+ } catch (error) {
592
+ Logger.error("Error in getStaticMap:", error);
593
+ throw new Error(error.message || "Failed to generate static map");
594
+ }
595
+ }
596
+ async getTimezone(latitude, longitude, timestamp) {
597
+ try {
598
+ const ts = timestamp ? Math.floor(timestamp / 1e3) : Math.floor(Date.now() / 1e3);
599
+ const response = await this.client.timezone({
600
+ params: {
601
+ location: { lat: latitude, lng: longitude },
602
+ timestamp: ts,
603
+ key: this.apiKey
604
+ }
605
+ });
606
+ const result = response.data;
607
+ if (result.status !== "OK") {
608
+ throw new Error(`Timezone API returned status: ${result.status}`);
609
+ }
610
+ const totalOffset = (result.rawOffset + result.dstOffset) * 1e3;
611
+ const localTime = new Date(ts * 1e3 + totalOffset).toISOString().replace("Z", "");
612
+ return {
613
+ timeZoneId: result.timeZoneId,
614
+ timeZoneName: result.timeZoneName,
615
+ utcOffset: result.rawOffset,
616
+ dstOffset: result.dstOffset,
617
+ localTime
618
+ };
619
+ } catch (error) {
620
+ Logger.error("Error in getTimezone:", error);
621
+ throw new Error(`Failed to get timezone for (${latitude}, ${longitude}): ${extractErrorMessage(error)}`);
622
+ }
623
+ }
624
+ async getElevation(locations) {
625
+ try {
626
+ const formattedLocations = locations.map((loc) => ({
627
+ lat: loc.latitude,
628
+ lng: loc.longitude
629
+ }));
630
+ const response = await this.client.elevation({
631
+ params: {
632
+ locations: formattedLocations,
633
+ key: this.apiKey
634
+ }
635
+ });
636
+ const result = response.data;
637
+ if (result.status !== "OK") {
638
+ throw new Error(`Failed to get elevation data with status: ${result.status}`);
639
+ }
640
+ return result.results.map((item, index) => ({
641
+ elevation: item.elevation,
642
+ location: formattedLocations[index]
643
+ }));
644
+ } catch (error) {
645
+ Logger.error("Error in getElevation:", error);
646
+ throw new Error(
647
+ `Failed to get elevation data for ${locations.length} location(s): ${extractErrorMessage(error)}`
648
+ );
649
+ }
650
+ }
651
+ };
652
+
653
+ // src/services/NewPlacesService.ts
654
+ import { PlacesClient } from "@googlemaps/places";
655
+ var NewPlacesService = class {
656
+ constructor(apiKey) {
657
+ this.defaultLanguage = "en";
658
+ this.placeFieldMask = [
659
+ "displayName",
660
+ "name",
661
+ "id",
662
+ "formattedAddress",
663
+ "location",
664
+ "utcOffsetMinutes",
665
+ "primaryType",
666
+ "types",
667
+ "regularOpeningHours.periods",
668
+ "regularOpeningHours.weekdayDescriptions",
669
+ "currentOpeningHours.openNow",
670
+ "currentOpeningHours.periods",
671
+ "currentOpeningHours.weekdayDescriptions",
672
+ "nationalPhoneNumber",
673
+ "websiteUri",
674
+ "priceLevel",
675
+ "rating",
676
+ "userRatingCount",
677
+ "editorialSummary",
678
+ "reviews.rating",
679
+ "reviews.text",
680
+ "reviews.publishTime",
681
+ "reviews.authorAttribution.displayName",
682
+ "reviews.authorAttribution.uri",
683
+ "reviews.authorAttribution.photoUri",
684
+ "reviews.googleMapsUri",
685
+ "reviews.flagContentUri",
686
+ "reviews.relativePublishTimeDescription",
687
+ "photos.heightPx",
688
+ "photos.widthPx",
689
+ "photos.name",
690
+ "photos.authorAttributions",
691
+ "photos.googleMapsUri",
692
+ "photos.flagContentUri",
693
+ "googleMapsUri",
694
+ // Parking & Accessibility
695
+ "parkingOptions",
696
+ "accessibilityOptions",
697
+ // Food & Drink
698
+ "servesVegetarianFood",
699
+ "servesBeer",
700
+ "servesWine",
701
+ "servesCocktails",
702
+ "servesBreakfast",
703
+ "servesLunch",
704
+ "servesDinner",
705
+ "servesBrunch",
706
+ "servesCoffee",
707
+ "servesDessert",
708
+ // Dining options
709
+ "dineIn",
710
+ "delivery",
711
+ "takeout",
712
+ "curbsidePickup",
713
+ "reservable",
714
+ // Pricing (Money structure preserved as returned by the API)
715
+ "priceRange",
716
+ "businessStatus",
717
+ // Atmosphere
718
+ "goodForGroups",
719
+ "goodForChildren",
720
+ "goodForWatchingSports",
721
+ "liveMusic",
722
+ "outdoorSeating",
723
+ "allowsDogs",
724
+ "menuForChildren",
725
+ "restroom",
726
+ // Payment
727
+ "paymentOptions",
728
+ // AI Summaries (region-limited)
729
+ "reviewSummary",
730
+ "generativeSummary"
731
+ ].join(",");
732
+ this.searchNearbyFieldMask = [
733
+ "places.displayName",
734
+ "places.name",
735
+ "places.id",
736
+ "places.formattedAddress",
737
+ "places.location",
738
+ "places.rating",
739
+ "places.userRatingCount",
740
+ "places.currentOpeningHours.openNow",
741
+ "places.primaryType",
742
+ "places.priceLevel",
743
+ "places.types",
744
+ "places.businessStatus",
745
+ "places.googleMapsUri",
746
+ "places.priceRange"
747
+ ].join(",");
748
+ /**
749
+ * FieldMask for Text Search (New). Includes every field required for
750
+ * budget/quality/category/opening-hours validation (price levels, price range
751
+ * with the original Money structure, types, business status, regular and
752
+ * current opening hours) plus nextPageToken for pagination.
753
+ */
754
+ this.searchTextFieldMask = [
755
+ "places.id",
756
+ "places.displayName",
757
+ "places.formattedAddress",
758
+ "places.location",
759
+ "places.primaryType",
760
+ "places.types",
761
+ "places.businessStatus",
762
+ "places.googleMapsUri",
763
+ "places.priceLevel",
764
+ "places.priceRange",
765
+ "places.rating",
766
+ "places.userRatingCount",
767
+ "places.regularOpeningHours.periods",
768
+ "places.regularOpeningHours.weekdayDescriptions",
769
+ "places.currentOpeningHours.openNow",
770
+ "places.currentOpeningHours.periods",
771
+ "places.currentOpeningHours.weekdayDescriptions",
772
+ "nextPageToken"
773
+ ].join(",");
774
+ this.apiKey = apiKey || "";
775
+ this.client = new PlacesClient({ apiKey: this.apiKey });
776
+ if (!this.apiKey) {
777
+ throw new Error("Google Maps API Key is required");
778
+ }
779
+ }
780
+ async searchNearby(params) {
781
+ try {
782
+ const request = {
783
+ locationRestriction: {
784
+ circle: {
785
+ center: {
786
+ latitude: params.location.lat,
787
+ longitude: params.location.lng
788
+ },
789
+ radius: params.radius || 1e3
790
+ }
791
+ },
792
+ maxResultCount: Math.min(params.maxResultCount || 20, 20),
793
+ languageCode: this.defaultLanguage
794
+ };
795
+ if (params.keyword) {
796
+ request.includedTypes = [params.keyword];
797
+ }
798
+ const [response] = await this.client.searchNearby(request, {
799
+ otherArgs: {
800
+ headers: {
801
+ "X-Goog-FieldMask": this.searchNearbyFieldMask
802
+ }
803
+ }
804
+ });
805
+ return (response.places || []).map((place) => this.transformSearchResult(place));
806
+ } catch (error) {
807
+ Logger.error("Error in searchNearby (New API):", error);
808
+ throw new Error(`Failed to search nearby places: ${this.extractErrorMessage(error)}`);
809
+ }
810
+ }
811
+ /**
812
+ * Backward-compatible Text Search (New) returning only the transformed
813
+ * places array.
814
+ */
815
+ async searchText(params) {
816
+ const result = await this.searchTextFull(params);
817
+ return result.places;
818
+ }
819
+ /**
820
+ * Text Search (New) with the full eatspot-discovery filter surface (REQ-027, REQ-109):
821
+ * - priceLevels: cumulative server-side economic filter (enum strings)
822
+ * - strictTypeFiltering: strict place-type filtering
823
+ * - minRating, includedType, openNow: server-side filters
824
+ * - locationBias, pageSize/pageToken: geographic bias and pagination
825
+ * - includeRaw: attaches the original API payload (raw_place per result and
826
+ * the full raw_response) so callers can validate against real data.
827
+ */
828
+ async searchTextFull(params) {
829
+ try {
830
+ const request = {
831
+ textQuery: params.textQuery,
832
+ languageCode: params.languageCode || this.defaultLanguage
833
+ };
834
+ const requestedCount = params.pageSize ?? params.maxResultCount ?? 10;
835
+ request.maxResultCount = Math.min(Math.max(1, Math.round(requestedCount)), 20);
836
+ if (params.pageToken) {
837
+ request.pageToken = params.pageToken;
838
+ }
839
+ if (params.locationBias) {
840
+ request.locationBias = {
841
+ circle: {
842
+ center: {
843
+ latitude: params.locationBias.lat,
844
+ longitude: params.locationBias.lng
845
+ },
846
+ radius: params.locationBias.radius || 5e3
847
+ }
848
+ };
849
+ }
850
+ if (params.openNow) {
851
+ request.openNow = true;
852
+ }
853
+ if (params.minRating != null) {
854
+ request.minRating = params.minRating;
855
+ }
856
+ if (params.includedType) {
857
+ request.includedType = params.includedType;
858
+ }
859
+ if (params.strictTypeFiltering != null) {
860
+ request.strictTypeFiltering = params.strictTypeFiltering;
861
+ }
862
+ if (params.priceLevels?.length) {
863
+ request.priceLevels = params.priceLevels;
864
+ }
865
+ if (params.pageToken) {
866
+ request.pageToken = params.pageToken;
867
+ }
868
+ const [response] = await this.client.searchText(request, {
869
+ otherArgs: {
870
+ headers: {
871
+ "X-Goog-FieldMask": this.searchTextFieldMask
872
+ }
873
+ }
874
+ });
875
+ const places = (response.places || []).map((place) => {
876
+ const transformed = this.transformSearchResult(place);
877
+ return params.includeRaw ? { ...transformed, raw_place: place } : transformed;
878
+ });
879
+ const paginated = response;
880
+ return {
881
+ places,
882
+ ...paginated.nextPageToken ? { nextPageToken: paginated.nextPageToken } : {},
883
+ fieldMask: this.searchTextFieldMask,
884
+ ...params.includeRaw ? { raw: response } : {}
885
+ };
886
+ } catch (error) {
887
+ Logger.error("Error in searchText (New API):", error);
888
+ throw new Error(`Failed to search places: ${this.extractErrorMessage(error)}`);
889
+ }
890
+ }
891
+ async getPhotoUri(photoName, maxWidthPx = 800) {
892
+ try {
893
+ const [response] = await this.client.getPhotoMedia({
894
+ name: `${photoName}/media`,
895
+ maxWidthPx,
896
+ skipHttpRedirect: true
897
+ });
898
+ return response.photoUri || "";
899
+ } catch (error) {
900
+ Logger.error("Error in getPhotoUri:", error);
901
+ throw new Error(`Failed to get photo URI: ${this.extractErrorMessage(error)}`);
902
+ }
903
+ }
904
+ async getPlaceDetails(placeId, includeRaw = false) {
905
+ try {
906
+ const placeName = `places/${placeId}`;
907
+ const [place] = await this.client.getPlace(
908
+ {
909
+ name: placeName,
910
+ languageCode: this.defaultLanguage
911
+ },
912
+ {
913
+ otherArgs: {
914
+ headers: {
915
+ "X-Goog-FieldMask": this.placeFieldMask
916
+ }
917
+ }
918
+ }
919
+ );
920
+ const transformed = this.transformPlaceResponse(place);
921
+ return includeRaw ? { ...transformed, raw_place: place, field_mask: this.placeFieldMask } : transformed;
922
+ } catch (error) {
923
+ Logger.error("Error in getPlaceDetails (New API):", error);
924
+ throw new Error(`Failed to get place details for ${placeId}: ${this.extractErrorMessage(error)}`);
925
+ }
926
+ }
927
+ transformSearchResult(place) {
928
+ return {
929
+ name: place.displayName?.text || "",
930
+ place_id: this.extractLegacyPlaceId(place),
931
+ formatted_address: place.formattedAddress || "",
932
+ geometry: {
933
+ location: {
934
+ lat: place.location?.latitude || 0,
935
+ lng: place.location?.longitude || 0
936
+ }
937
+ },
938
+ primary_type: place.primaryType || null,
939
+ types: place.types || [],
940
+ business_status: place.businessStatus || null,
941
+ google_maps_uri: place.googleMapsUri || null,
942
+ price_level: place.priceLevel || null,
943
+ // Raw Money object preserved exactly as returned by the API
944
+ // ({ startPrice/endPrice: { currencyCode, units, nanos } })
945
+ price_range: place.priceRange || null,
946
+ rating: place.rating || 0,
947
+ user_ratings_total: place.userRatingCount || 0,
948
+ opening_hours: {
949
+ open_now: place.currentOpeningHours?.openNow ?? null
950
+ },
951
+ regular_opening_hours: place.regularOpeningHours || null,
952
+ current_opening_hours: place.currentOpeningHours || null
953
+ };
954
+ }
955
+ transformPlaceResponse(place) {
956
+ const parking = place.parkingOptions ? Object.fromEntries(Object.entries(place.parkingOptions).filter(([, v]) => v === true)) : void 0;
957
+ const accessibility = place.accessibilityOptions ? Object.fromEntries(Object.entries(place.accessibilityOptions).filter(([, v]) => v === true)) : void 0;
958
+ const diningOptions = {};
959
+ if (place.dineIn) diningOptions.dine_in = true;
960
+ if (place.delivery) diningOptions.delivery = true;
961
+ if (place.takeout) diningOptions.takeout = true;
962
+ if (place.curbsidePickup) diningOptions.curbside_pickup = true;
963
+ if (place.reservable) diningOptions.reservable = true;
964
+ const serves = {};
965
+ if (place.servesVegetarianFood) serves.vegetarian_food = true;
966
+ if (place.servesBeer) serves.beer = true;
967
+ if (place.servesWine) serves.wine = true;
968
+ if (place.servesCocktails) serves.cocktails = true;
969
+ if (place.servesBreakfast) serves.breakfast = true;
970
+ if (place.servesLunch) serves.lunch = true;
971
+ if (place.servesDinner) serves.dinner = true;
972
+ if (place.servesBrunch) serves.brunch = true;
973
+ if (place.servesCoffee) serves.coffee = true;
974
+ if (place.servesDessert) serves.dessert = true;
975
+ const atmosphere = {};
976
+ if (place.goodForGroups) atmosphere.good_for_groups = true;
977
+ if (place.goodForChildren) atmosphere.good_for_children = true;
978
+ if (place.goodForWatchingSports) atmosphere.good_for_watching_sports = true;
979
+ if (place.liveMusic) atmosphere.live_music = true;
980
+ if (place.outdoorSeating) atmosphere.outdoor_seating = true;
981
+ if (place.allowsDogs) atmosphere.allows_dogs = true;
982
+ if (place.menuForChildren) atmosphere.menu_for_children = true;
983
+ if (place.restroom) atmosphere.restroom = true;
984
+ return {
985
+ name: place.displayName?.text || place.name || "",
986
+ place_id: this.extractLegacyPlaceId(place),
987
+ formatted_address: place.formattedAddress || "",
988
+ google_maps_uri: place.googleMapsUri || "",
989
+ geometry: {
990
+ location: {
991
+ lat: place.location?.latitude || 0,
992
+ lng: place.location?.longitude || 0
993
+ }
994
+ },
995
+ primary_type: place.primaryType || null,
996
+ types: place.types || [],
997
+ business_status: place.businessStatus || null,
998
+ rating: place.rating || 0,
999
+ user_ratings_total: place.userRatingCount || 0,
1000
+ // Raw Money object preserved exactly as returned by the API
1001
+ // ({ startPrice/endPrice: { currencyCode, units, nanos } })
1002
+ price_range: place.priceRange || null,
1003
+ opening_hours: place.regularOpeningHours ? {
1004
+ open_now: this.isCurrentlyOpen(
1005
+ place.regularOpeningHours,
1006
+ place.utcOffsetMinutes,
1007
+ place.currentOpeningHours
1008
+ ),
1009
+ weekday_text: this.formatOpeningHours(place.regularOpeningHours)
1010
+ } : void 0,
1011
+ current_opening_hours: place.currentOpeningHours || null,
1012
+ formatted_phone_number: place.nationalPhoneNumber || "",
1013
+ website: place.websiteUri || "",
1014
+ price_level: place.priceLevel || 0,
1015
+ editorial_summary: place.editorialSummary?.text || null,
1016
+ ...Object.keys(parking || {}).length > 0 ? { parking } : {},
1017
+ ...Object.keys(accessibility || {}).length > 0 ? { accessibility } : {},
1018
+ ...Object.keys(diningOptions).length > 0 ? { dining_options: diningOptions } : {},
1019
+ ...Object.keys(serves).length > 0 ? { serves } : {},
1020
+ ...Object.keys(atmosphere).length > 0 ? { atmosphere } : {},
1021
+ ...place.paymentOptions ? {
1022
+ payment_options: Object.fromEntries(
1023
+ Object.entries(place.paymentOptions).filter(([k]) => !k.startsWith("_"))
1024
+ )
1025
+ } : {},
1026
+ ...place.reviewSummary?.text?.text ? {
1027
+ review_summary: place.reviewSummary.text.text,
1028
+ review_summary_attribution: {
1029
+ disclosure_text: place.reviewSummary.disclosureText?.text || "",
1030
+ flag_content_uri: place.reviewSummary.flagContentUri || "",
1031
+ reviews_uri: place.reviewSummary.reviewsUri || ""
1032
+ }
1033
+ } : {},
1034
+ ...place.generativeSummary?.overview?.text ? {
1035
+ generative_summary: place.generativeSummary.overview.text,
1036
+ generative_summary_attribution: {
1037
+ disclosure_text: place.generativeSummary.disclosureText?.text || "",
1038
+ flag_content_uri: place.generativeSummary.flagContentUri || ""
1039
+ }
1040
+ } : {},
1041
+ reviews: place.reviews?.map((review) => ({
1042
+ rating: review.rating || 0,
1043
+ text: review.text?.text || "",
1044
+ language: review.text?.languageCode || null,
1045
+ time: review.publishTime?.seconds || 0,
1046
+ author_name: review.authorAttribution?.displayName || "",
1047
+ author_uri: review.authorAttribution?.uri || "",
1048
+ author_photo_uri: review.authorAttribution?.photoUri || "",
1049
+ google_maps_uri: review.googleMapsUri || "",
1050
+ flag_content_uri: review.flagContentUri || "",
1051
+ relative_publish_time_description: review.relativePublishTimeDescription || ""
1052
+ })) || [],
1053
+ photos: place.photos?.map((photo) => ({
1054
+ photo_reference: photo.name || "",
1055
+ height: photo.heightPx || 0,
1056
+ width: photo.widthPx || 0,
1057
+ google_maps_uri: photo.googleMapsUri || "",
1058
+ flag_content_uri: photo.flagContentUri || "",
1059
+ author_attributions: photo.authorAttributions?.map((author) => ({
1060
+ display_name: author.displayName || "",
1061
+ uri: author.uri || "",
1062
+ photo_uri: author.photoUri || ""
1063
+ })) || []
1064
+ })) || []
1065
+ };
1066
+ }
1067
+ extractLegacyPlaceId(place) {
1068
+ const resourceName = place?.name;
1069
+ if (typeof resourceName === "string" && resourceName.startsWith("places/")) {
1070
+ const legacyId = resourceName.substring("places/".length);
1071
+ if (legacyId) {
1072
+ return legacyId;
1073
+ }
1074
+ }
1075
+ return place?.id || "";
1076
+ }
1077
+ isCurrentlyOpen(openingHours, utcOffsetMinutes, currentOpeningHours) {
1078
+ if (typeof currentOpeningHours?.openNow === "boolean") {
1079
+ return currentOpeningHours.openNow;
1080
+ }
1081
+ if (typeof openingHours?.openNow === "boolean") {
1082
+ return openingHours.openNow;
1083
+ }
1084
+ const periods = openingHours?.periods;
1085
+ if (!Array.isArray(periods) || periods.length === 0) {
1086
+ return false;
1087
+ }
1088
+ const minutesInDay = 24 * 60;
1089
+ const minutesInWeek = minutesInDay * 7;
1090
+ const { day: localDay, minutes: localMinutes } = this.getLocalTimeComponents(utcOffsetMinutes);
1091
+ const localTimeValue = localDay * minutesInDay + localMinutes;
1092
+ const dayMapping = {
1093
+ SUNDAY: 0,
1094
+ MONDAY: 1,
1095
+ TUESDAY: 2,
1096
+ WEDNESDAY: 3,
1097
+ THURSDAY: 4,
1098
+ FRIDAY: 5,
1099
+ SATURDAY: 6
1100
+ };
1101
+ const toDayNumber = (value) => {
1102
+ if (typeof value === "number" && value >= 0 && value <= 6) {
1103
+ return value;
1104
+ }
1105
+ if (typeof value === "string") {
1106
+ const normalized = value.toUpperCase();
1107
+ if (normalized in dayMapping) {
1108
+ return dayMapping[normalized];
1109
+ }
1110
+ }
1111
+ return void 0;
1112
+ };
1113
+ const toMinutes = (time) => {
1114
+ if (!time) {
1115
+ return void 0;
1116
+ }
1117
+ const hours = typeof time.hours === "number" ? time.hours : Number(time.hours ?? NaN);
1118
+ const minutes = typeof time.minutes === "number" ? time.minutes : Number(time.minutes ?? NaN);
1119
+ if (!Number.isFinite(hours) || !Number.isFinite(minutes)) {
1120
+ return void 0;
1121
+ }
1122
+ return hours * 60 + minutes;
1123
+ };
1124
+ for (const period of periods) {
1125
+ const openDay = toDayNumber(period?.openDay);
1126
+ const closeDay = toDayNumber(period?.closeDay ?? period?.openDay);
1127
+ const openMinutes = toMinutes(period?.openTime);
1128
+ const closeMinutes = toMinutes(period?.closeTime);
1129
+ if (openDay === void 0 || openMinutes === void 0) {
1130
+ continue;
1131
+ }
1132
+ const start = openDay * minutesInDay + openMinutes;
1133
+ let end;
1134
+ if (closeDay === void 0 || closeMinutes === void 0) {
1135
+ end = start + minutesInDay;
1136
+ } else {
1137
+ end = closeDay * minutesInDay + closeMinutes;
1138
+ }
1139
+ if (end <= start) {
1140
+ end += minutesInWeek;
1141
+ }
1142
+ let comparableLocalTime = localTimeValue;
1143
+ while (comparableLocalTime < start) {
1144
+ comparableLocalTime += minutesInWeek;
1145
+ }
1146
+ if (comparableLocalTime >= start && comparableLocalTime < end) {
1147
+ return true;
1148
+ }
1149
+ }
1150
+ return false;
1151
+ }
1152
+ getLocalTimeComponents(utcOffsetMinutes) {
1153
+ const now = /* @__PURE__ */ new Date();
1154
+ if (typeof utcOffsetMinutes === "number" && Number.isFinite(utcOffsetMinutes)) {
1155
+ const localTime = new Date(now.getTime() + utcOffsetMinutes * 6e4);
1156
+ return {
1157
+ day: localTime.getUTCDay(),
1158
+ minutes: localTime.getUTCHours() * 60 + localTime.getUTCMinutes()
1159
+ };
1160
+ }
1161
+ return {
1162
+ day: now.getDay(),
1163
+ minutes: now.getHours() * 60 + now.getMinutes()
1164
+ };
1165
+ }
1166
+ formatOpeningHours(openingHours) {
1167
+ return openingHours?.weekdayDescriptions || [];
1168
+ }
1169
+ extractErrorMessage(error) {
1170
+ const statusCode = error?.code;
1171
+ const message = error?.message || error?.details;
1172
+ if (statusCode === 7 || statusCode === 403) {
1173
+ return "API key invalid or Places API (New) not enabled. Check: console.cloud.google.com \u2192 APIs & Services \u2192 Enable 'Places API (New)'";
1174
+ }
1175
+ if (statusCode === 8 || statusCode === 429) {
1176
+ return "API quota exceeded. Wait and retry, or check quota at console.cloud.google.com \u2192 Quotas";
1177
+ }
1178
+ return message || (error instanceof Error ? error.message : String(error));
1179
+ }
1180
+ };
1181
+
1182
+ // src/services/PlacesSearcher.ts
1183
+ function parseCoordinateInput(input) {
1184
+ const match = input.match(COORDINATE_STRING_PATTERN);
1185
+ if (!match) {
1186
+ return null;
1187
+ }
1188
+ const lat = parseFloat(match[1]);
1189
+ const lng = parseFloat(match[2]);
1190
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
1191
+ return null;
1192
+ }
1193
+ if (lat < -90 || lat > 90 || lng < -180 || lng > 180) {
1194
+ return null;
1195
+ }
1196
+ return { lat, lng };
1197
+ }
1198
+ function toRouteWaypoint(loc) {
1199
+ if (loc.placeId) {
1200
+ return { placeId: loc.placeId };
1201
+ }
1202
+ return { latLng: { latitude: loc.lat, longitude: loc.lng } };
1203
+ }
1204
+ var PlacesSearcher = class {
1205
+ constructor(apiKey) {
1206
+ this.mapsTools = new GoogleMapsTools(apiKey);
1207
+ this.newPlacesService = new NewPlacesService(apiKey);
1208
+ this.routesService = new RoutesService(apiKey);
1209
+ }
1210
+ async searchNearby(params) {
1211
+ try {
1212
+ const location = await this.mapsTools.getLocation(params.center);
1213
+ const places = await this.newPlacesService.searchNearby({
1214
+ location,
1215
+ keyword: params.keyword,
1216
+ radius: params.radius
1217
+ });
1218
+ let filteredPlaces = places;
1219
+ if (params.openNow) {
1220
+ filteredPlaces = filteredPlaces.filter((p) => p.opening_hours?.open_now === true);
1221
+ }
1222
+ if (params.minRating) {
1223
+ filteredPlaces = filteredPlaces.filter((p) => (p.rating || 0) >= (params.minRating || 0));
1224
+ }
1225
+ return {
1226
+ location,
1227
+ success: true,
1228
+ data: filteredPlaces.map((place) => ({
1229
+ name: place.name,
1230
+ place_id: place.place_id,
1231
+ address: place.formatted_address,
1232
+ location: place.geometry.location,
1233
+ primary_type: place.primary_type || null,
1234
+ price_level: place.price_level || null,
1235
+ rating: place.rating,
1236
+ total_ratings: place.user_ratings_total,
1237
+ open_now: place.opening_hours?.open_now
1238
+ }))
1239
+ };
1240
+ } catch (error) {
1241
+ return {
1242
+ success: false,
1243
+ error: error instanceof Error ? error.message : "An error occurred during search"
1244
+ };
1245
+ }
1246
+ }
1247
+ async searchText(params) {
1248
+ try {
1249
+ const result = await this.newPlacesService.searchTextFull({
1250
+ textQuery: params.query,
1251
+ locationBias: params.locationBias ? {
1252
+ lat: params.locationBias.latitude,
1253
+ lng: params.locationBias.longitude,
1254
+ radius: params.locationBias.radius
1255
+ } : void 0,
1256
+ openNow: params.openNow,
1257
+ minRating: params.minRating,
1258
+ includedType: params.includedType,
1259
+ strictTypeFiltering: params.strictTypeFiltering,
1260
+ priceLevels: params.priceLevels,
1261
+ pageSize: params.pageSize,
1262
+ pageToken: params.pageToken,
1263
+ languageCode: params.languageCode,
1264
+ includeRaw: params.includeRaw
1265
+ });
1266
+ return {
1267
+ success: true,
1268
+ data: result.places.map((place) => ({
1269
+ name: place.name,
1270
+ place_id: place.place_id,
1271
+ address: place.formatted_address,
1272
+ location: place.geometry.location,
1273
+ primary_type: place.primary_type || null,
1274
+ types: place.types || [],
1275
+ business_status: place.business_status || null,
1276
+ google_maps_uri: place.google_maps_uri || null,
1277
+ price_level: place.price_level || null,
1278
+ price_range: place.price_range || null,
1279
+ rating: place.rating,
1280
+ total_ratings: place.user_ratings_total,
1281
+ open_now: place.opening_hours?.open_now,
1282
+ regular_opening_hours: place.regular_opening_hours || null,
1283
+ current_opening_hours: place.current_opening_hours || null,
1284
+ ...place.raw_place ? { raw_place: place.raw_place } : {}
1285
+ })),
1286
+ ...result.nextPageToken ? { nextPageToken: result.nextPageToken } : {},
1287
+ fieldMask: result.fieldMask,
1288
+ ...params.includeRaw && result.raw ? { raw_response: result.raw } : {}
1289
+ };
1290
+ } catch (error) {
1291
+ return {
1292
+ success: false,
1293
+ error: error instanceof Error ? error.message : "An error occurred during text search"
1294
+ };
1295
+ }
1296
+ }
1297
+ async getPlaceDetails(placeId, maxPhotos = 0, includeRaw = false) {
1298
+ try {
1299
+ const details = await this.newPlacesService.getPlaceDetails(placeId, includeRaw);
1300
+ let photos;
1301
+ if (maxPhotos > 0 && details.photos?.length > 0) {
1302
+ const photosToFetch = details.photos.slice(0, maxPhotos);
1303
+ photos = [];
1304
+ for (const photo of photosToFetch) {
1305
+ try {
1306
+ const url = await this.newPlacesService.getPhotoUri(photo.photo_reference);
1307
+ photos.push({
1308
+ url,
1309
+ width: photo.width,
1310
+ height: photo.height,
1311
+ google_maps_uri: photo.google_maps_uri,
1312
+ flag_content_uri: photo.flag_content_uri,
1313
+ author_attributions: photo.author_attributions
1314
+ });
1315
+ } catch {
1316
+ }
1317
+ }
1318
+ }
1319
+ return {
1320
+ success: true,
1321
+ ...details.field_mask ? { fieldMask: details.field_mask } : {},
1322
+ data: {
1323
+ name: details.name,
1324
+ address: details.formatted_address,
1325
+ google_maps_uri: details.google_maps_uri,
1326
+ location: details.geometry?.location,
1327
+ primary_type: details.primary_type || null,
1328
+ types: details.types || [],
1329
+ business_status: details.business_status || null,
1330
+ rating: details.rating,
1331
+ total_ratings: details.user_ratings_total,
1332
+ // Raw Money object (startPrice/endPrice: currencyCode/units/nanos)
1333
+ price_range: details.price_range || null,
1334
+ current_opening_hours: details.current_opening_hours || null,
1335
+ opening_hours: details.opening_hours,
1336
+ phone: details.formatted_phone_number,
1337
+ website: details.website,
1338
+ price_level: details.price_level,
1339
+ editorial_summary: details.editorial_summary || null,
1340
+ ...details.parking ? { parking: details.parking } : {},
1341
+ ...details.accessibility ? { accessibility: details.accessibility } : {},
1342
+ ...details.dining_options ? { dining_options: details.dining_options } : {},
1343
+ ...details.serves ? { serves: details.serves } : {},
1344
+ ...details.atmosphere ? { atmosphere: details.atmosphere } : {},
1345
+ ...details.payment_options ? { payment_options: details.payment_options } : {},
1346
+ ...details.review_summary ? { review_summary: details.review_summary } : {},
1347
+ ...details.review_summary_attribution ? { review_summary_attribution: details.review_summary_attribution } : {},
1348
+ ...details.generative_summary ? { generative_summary: details.generative_summary } : {},
1349
+ ...details.generative_summary_attribution ? { generative_summary_attribution: details.generative_summary_attribution } : {},
1350
+ photo_count: details.photos?.length || 0,
1351
+ ...photos && photos.length > 0 ? { photos } : {},
1352
+ ...details.raw_place ? { raw_place: details.raw_place } : {},
1353
+ reviews: details.reviews?.map((review) => ({
1354
+ rating: review.rating,
1355
+ text: review.text,
1356
+ language: review.language || null,
1357
+ time: review.time,
1358
+ author_name: review.author_name,
1359
+ author_uri: review.author_uri,
1360
+ author_photo_uri: review.author_photo_uri,
1361
+ google_maps_uri: review.google_maps_uri,
1362
+ flag_content_uri: review.flag_content_uri,
1363
+ relative_publish_time_description: review.relative_publish_time_description
1364
+ }))
1365
+ }
1366
+ };
1367
+ } catch (error) {
1368
+ return {
1369
+ success: false,
1370
+ error: error instanceof Error ? error.message : "An error occurred while getting place details"
1371
+ };
1372
+ }
1373
+ }
1374
+ async geocode(address) {
1375
+ try {
1376
+ const result = await this.mapsTools.geocode(address);
1377
+ return {
1378
+ success: true,
1379
+ data: result
1380
+ };
1381
+ } catch (error) {
1382
+ return {
1383
+ success: false,
1384
+ error: error instanceof Error ? error.message : "An error occurred while geocoding address"
1385
+ };
1386
+ }
1387
+ }
1388
+ async reverseGeocode(latitude, longitude) {
1389
+ try {
1390
+ const result = await this.mapsTools.reverseGeocode(latitude, longitude);
1391
+ return {
1392
+ success: true,
1393
+ data: result
1394
+ };
1395
+ } catch (error) {
1396
+ return {
1397
+ success: false,
1398
+ error: error instanceof Error ? error.message : "An error occurred during reverse geocoding"
1399
+ };
1400
+ }
1401
+ }
1402
+ async calculateDistanceMatrix(origins, destinations, mode = "driving", departure_time, avoid_tolls, avoid_highways) {
1403
+ try {
1404
+ const result = await this.routesService.computeRouteMatrix({
1405
+ origins,
1406
+ destinations,
1407
+ mode,
1408
+ ...departure_time ? { departureTime: new Date(departure_time) } : {},
1409
+ ...avoid_tolls !== void 0 ? { avoidTolls: avoid_tolls } : {},
1410
+ ...avoid_highways !== void 0 ? { avoidHighways: avoid_highways } : {}
1411
+ });
1412
+ return {
1413
+ success: true,
1414
+ data: result
1415
+ };
1416
+ } catch (error) {
1417
+ return {
1418
+ success: false,
1419
+ error: error instanceof Error ? error.message : "An error occurred while calculating distance matrix"
1420
+ };
1421
+ }
1422
+ }
1423
+ async getDirections(origin, destination, mode = "driving", departure_time, arrival_time, avoid_tolls, avoid_highways) {
1424
+ try {
1425
+ const departureTime = departure_time ? new Date(departure_time) : void 0;
1426
+ const arrivalTime = arrival_time ? new Date(arrival_time) : void 0;
1427
+ const result = await this.routesService.computeRoutes({
1428
+ origin,
1429
+ destination,
1430
+ mode,
1431
+ ...departureTime ? { departureTime } : {},
1432
+ ...arrivalTime ? { arrivalTime } : {},
1433
+ ...avoid_tolls !== void 0 ? { avoidTolls: avoid_tolls } : {},
1434
+ ...avoid_highways !== void 0 ? { avoidHighways: avoid_highways } : {}
1435
+ });
1436
+ return {
1437
+ success: true,
1438
+ data: result
1439
+ };
1440
+ } catch (error) {
1441
+ return {
1442
+ success: false,
1443
+ error: error instanceof Error ? error.message : "An error occurred while getting directions"
1444
+ };
1445
+ }
1446
+ }
1447
+ async getTimezone(latitude, longitude, timestamp) {
1448
+ try {
1449
+ const result = await this.mapsTools.getTimezone(latitude, longitude, timestamp);
1450
+ return { success: true, data: result };
1451
+ } catch (error) {
1452
+ return {
1453
+ success: false,
1454
+ error: error instanceof Error ? error.message : "An error occurred while getting timezone"
1455
+ };
1456
+ }
1457
+ }
1458
+ async getWeather(latitude, longitude, type = "current", forecastDays, forecastHours) {
1459
+ try {
1460
+ const result = await this.mapsTools.getWeather(latitude, longitude, type, forecastDays, forecastHours);
1461
+ return { success: true, data: result };
1462
+ } catch (error) {
1463
+ return {
1464
+ success: false,
1465
+ error: error instanceof Error ? error.message : "An error occurred while getting weather"
1466
+ };
1467
+ }
1468
+ }
1469
+ async getAirQuality(latitude, longitude, includeHealthRecommendations, includePollutants) {
1470
+ try {
1471
+ const result = await this.mapsTools.getAirQuality(
1472
+ latitude,
1473
+ longitude,
1474
+ includeHealthRecommendations,
1475
+ includePollutants
1476
+ );
1477
+ return { success: true, data: result };
1478
+ } catch (error) {
1479
+ return {
1480
+ success: false,
1481
+ error: error instanceof Error ? error.message : "An error occurred while getting air quality"
1482
+ };
1483
+ }
1484
+ }
1485
+ async getStaticMap(params) {
1486
+ try {
1487
+ const result = await this.mapsTools.getStaticMap(params);
1488
+ return { success: true, data: result };
1489
+ } catch (error) {
1490
+ return {
1491
+ success: false,
1492
+ error: error instanceof Error ? error.message : "An error occurred while generating static map"
1493
+ };
1494
+ }
1495
+ }
1496
+ async searchAlongRoute(params) {
1497
+ try {
1498
+ const result = await this.mapsTools.searchAlongRoute(params);
1499
+ return { success: true, data: result };
1500
+ } catch (error) {
1501
+ return {
1502
+ success: false,
1503
+ error: error instanceof Error ? error.message : "An error occurred while searching along route"
1504
+ };
1505
+ }
1506
+ }
1507
+ // --------------- Composite Tools ---------------
1508
+ /**
1509
+ * Resolve a location string to one concrete place, exactly once: geocoding
1510
+ * first, then Places Text Search for informal names it does not index.
1511
+ *
1512
+ * Throws rather than returning { success: false } — the plan_route and
1513
+ * explore_area actions JSON.stringify(result.data) without checking the flag,
1514
+ * so a failure object would surface as an empty success.
1515
+ */
1516
+ async resolveLocation(input) {
1517
+ if (COORDINATE_STRING_PATTERN.test(input)) {
1518
+ const coordinates = parseCoordinateInput(input);
1519
+ if (!coordinates) {
1520
+ throw new Error(`Failed to resolve location: ${input} (not a valid "latitude,longitude" pair)`);
1521
+ }
1522
+ const displayGeocode = await this.geocode(input);
1523
+ return {
1524
+ originalName: input,
1525
+ address: displayGeocode.success && displayGeocode.data ? displayGeocode.data.formatted_address : input,
1526
+ lat: coordinates.lat,
1527
+ lng: coordinates.lng,
1528
+ placeId: ""
1529
+ };
1530
+ }
1531
+ const geo = await this.geocode(input);
1532
+ if (geo.success && geo.data) {
1533
+ return {
1534
+ originalName: input,
1535
+ address: geo.data.formatted_address,
1536
+ lat: geo.data.location.lat,
1537
+ lng: geo.data.location.lng,
1538
+ placeId: geo.data.place_id
1539
+ };
1540
+ }
1541
+ const textSearch = await this.searchText({ query: input });
1542
+ if (textSearch.success && textSearch.data && textSearch.data.length > 0) {
1543
+ const topMatch = textSearch.data[0];
1544
+ return {
1545
+ originalName: input,
1546
+ address: topMatch.address || topMatch.name,
1547
+ lat: topMatch.location.lat,
1548
+ lng: topMatch.location.lng,
1549
+ placeId: topMatch.place_id || ""
1550
+ };
1551
+ }
1552
+ throw new Error(
1553
+ `Failed to resolve location: ${input} (geocoding: ${geo.error || "no result"}; places text search: ${textSearch.error || "no result"})`
1554
+ );
1555
+ }
1556
+ async exploreArea(params) {
1557
+ const types = params.types || ["restaurant", "cafe", "tourist_attraction"];
1558
+ const radius = params.radius || 1e3;
1559
+ const topN = params.topN || 3;
1560
+ const resolved = await this.resolveLocation(params.location);
1561
+ const { lat, lng } = resolved;
1562
+ const categories = [];
1563
+ for (const type of types) {
1564
+ const search = await this.searchNearby({
1565
+ center: { value: `${lat},${lng}`, isCoordinates: true },
1566
+ keyword: type,
1567
+ radius
1568
+ });
1569
+ if (!search.success || !search.data) continue;
1570
+ const topPlaces = search.data.slice(0, topN);
1571
+ const detailed = [];
1572
+ for (const place of topPlaces) {
1573
+ if (!place.place_id) continue;
1574
+ const details = await this.getPlaceDetails(place.place_id);
1575
+ detailed.push({
1576
+ name: place.name,
1577
+ address: place.address,
1578
+ rating: place.rating,
1579
+ total_ratings: place.total_ratings,
1580
+ open_now: place.open_now,
1581
+ phone: details.data?.phone,
1582
+ website: details.data?.website
1583
+ });
1584
+ }
1585
+ categories.push({ type, count: search.data.length, top: detailed });
1586
+ }
1587
+ return {
1588
+ success: true,
1589
+ data: {
1590
+ location: { address: resolved.address, lat, lng },
1591
+ radius,
1592
+ categories
1593
+ }
1594
+ };
1595
+ }
1596
+ async planRoute(params) {
1597
+ const mode = params.mode || "driving";
1598
+ const stops = params.stops;
1599
+ if (stops.length < 2) throw new Error("Need at least 2 stops");
1600
+ const resolvedStops = [];
1601
+ for (const stop of stops) {
1602
+ resolvedStops.push(await this.resolveLocation(stop));
1603
+ }
1604
+ const origin = toRouteWaypoint(resolvedStops[0]);
1605
+ const destination = toRouteWaypoint(resolvedStops[resolvedStops.length - 1]);
1606
+ const intermediates = stops.length > 2 ? resolvedStops.slice(1, -1).map((s) => toRouteWaypoint(s)) : void 0;
1607
+ const shouldOptimize = params.optimize !== false && stops.length > 3 && mode !== "transit";
1608
+ const routeResult = await this.routesService.computeRoutes({
1609
+ origin,
1610
+ destination,
1611
+ mode,
1612
+ intermediates,
1613
+ optimizeWaypointOrder: shouldOptimize,
1614
+ originLabel: stops[0],
1615
+ destinationLabel: stops[stops.length - 1],
1616
+ ...params.departure_time ? { departureTime: new Date(params.departure_time) } : {},
1617
+ ...params.avoid_tolls !== void 0 ? { avoidTolls: params.avoid_tolls } : {},
1618
+ ...params.avoid_highways !== void 0 ? { avoidHighways: params.avoid_highways } : {}
1619
+ });
1620
+ const route = routeResult.routes[0];
1621
+ const routeLegs = route?.legs || [];
1622
+ let orderedStops;
1623
+ if (shouldOptimize && routeResult.optimizedIntermediateWaypointIndex) {
1624
+ const optimizedOrder = routeResult.optimizedIntermediateWaypointIndex;
1625
+ const intermediateResolved = resolvedStops.slice(1, -1);
1626
+ orderedStops = [
1627
+ resolvedStops[0],
1628
+ ...optimizedOrder.map((i) => intermediateResolved[i]),
1629
+ resolvedStops[resolvedStops.length - 1]
1630
+ ];
1631
+ } else {
1632
+ orderedStops = resolvedStops;
1633
+ }
1634
+ const legs = [];
1635
+ let totalDistance = 0;
1636
+ let totalDuration = 0;
1637
+ for (let i = 0; i < orderedStops.length - 1; i++) {
1638
+ const leg = routeLegs[i];
1639
+ if (leg) {
1640
+ const distMeters = leg.distanceMeters || 0;
1641
+ const durSeconds = parseDuration(leg.duration);
1642
+ totalDistance += distMeters;
1643
+ totalDuration += durSeconds;
1644
+ legs.push({
1645
+ from: orderedStops[i].originalName,
1646
+ to: orderedStops[i + 1].originalName,
1647
+ distance: formatDistance(distMeters),
1648
+ duration: formatDuration(durSeconds)
1649
+ });
1650
+ } else {
1651
+ legs.push({
1652
+ from: orderedStops[i].originalName,
1653
+ to: orderedStops[i + 1].originalName,
1654
+ distance: "unknown",
1655
+ duration: "unknown",
1656
+ note: "Directions unavailable for this segment"
1657
+ });
1658
+ }
1659
+ }
1660
+ return {
1661
+ success: true,
1662
+ data: {
1663
+ mode,
1664
+ optimized: shouldOptimize,
1665
+ stops: orderedStops.map((s) => `${s.originalName} (${s.address})`),
1666
+ legs,
1667
+ total_distance: `${(totalDistance / 1e3).toFixed(1)} km`,
1668
+ total_duration: `${Math.round(totalDuration / 60)} min`
1669
+ }
1670
+ };
1671
+ }
1672
+ async comparePlaces(params) {
1673
+ const limit = params.limit || 5;
1674
+ const search = await this.searchText({ query: params.query });
1675
+ if (!search.success || !search.data) throw new Error(search.error || "Search failed");
1676
+ const places = search.data.slice(0, limit);
1677
+ const compared = [];
1678
+ for (const place of places) {
1679
+ const details = await this.getPlaceDetails(place.place_id);
1680
+ compared.push({
1681
+ name: place.name,
1682
+ address: place.address,
1683
+ primary_type: details.data?.primary_type || place.primary_type || null,
1684
+ rating: place.rating,
1685
+ total_ratings: place.total_ratings,
1686
+ opening_hours: details.data?.opening_hours,
1687
+ phone: details.data?.phone,
1688
+ website: details.data?.website,
1689
+ price_level: details.data?.price_level,
1690
+ ...details.data?.parking ? { parking: details.data.parking } : {},
1691
+ ...details.data?.serves ? { serves: details.data.serves } : {},
1692
+ ...details.data?.atmosphere ? { atmosphere: details.data.atmosphere } : {},
1693
+ ...details.data?.dining_options ? { dining_options: details.data.dining_options } : {}
1694
+ });
1695
+ }
1696
+ if (params.userLocation && compared.length > 0) {
1697
+ const origin = `${params.userLocation.latitude},${params.userLocation.longitude}`;
1698
+ const destinations = places.map((p) => `${p.location.lat},${p.location.lng}`);
1699
+ const matrix = await this.calculateDistanceMatrix([origin], destinations, "driving");
1700
+ if (matrix.success && matrix.data) {
1701
+ for (let i = 0; i < compared.length; i++) {
1702
+ compared[i].distance = matrix.data.distances[0]?.[i]?.text;
1703
+ compared[i].drive_time = matrix.data.durations[0]?.[i]?.text;
1704
+ }
1705
+ }
1706
+ }
1707
+ return { success: true, data: compared };
1708
+ }
1709
+ async getElevation(locations) {
1710
+ try {
1711
+ const result = await this.mapsTools.getElevation(locations);
1712
+ return {
1713
+ success: true,
1714
+ data: result
1715
+ };
1716
+ } catch (error) {
1717
+ return {
1718
+ success: false,
1719
+ error: error instanceof Error ? error.message : "An error occurred while getting elevation data"
1720
+ };
1721
+ }
1722
+ }
1723
+ async localRankTracker(params) {
1724
+ try {
1725
+ const gridSize = params.gridSize || 3;
1726
+ const spacingMeters = params.gridSpacing || 1e3;
1727
+ const { latitude: centerLat, longitude: centerLng } = params.center;
1728
+ const half = Math.floor(gridSize / 2);
1729
+ const metersPerDegreeLat = 111320;
1730
+ const metersPerDegreeLng = 111320 * Math.cos(centerLat * Math.PI / 180);
1731
+ const gridPoints = [];
1732
+ for (let row = 0; row < gridSize; row++) {
1733
+ for (let col = 0; col < gridSize; col++) {
1734
+ const rowOffset = (row - half) * spacingMeters;
1735
+ const colOffset = (col - half) * spacingMeters;
1736
+ gridPoints.push({
1737
+ row,
1738
+ col,
1739
+ lat: centerLat + rowOffset / metersPerDegreeLat,
1740
+ lng: centerLng + colOffset / metersPerDegreeLng
1741
+ });
1742
+ }
1743
+ }
1744
+ let targetName = "";
1745
+ try {
1746
+ const details = await this.getPlaceDetails(params.placeId);
1747
+ if (details.success && details.data) {
1748
+ targetName = details.data.name;
1749
+ }
1750
+ } catch {
1751
+ }
1752
+ const keywordResults = [];
1753
+ for (const keyword of params.keywords) {
1754
+ const gridResults = await this.scanKeywordGrid(keyword, params.placeId, gridPoints, spacingMeters);
1755
+ keywordResults.push({ keyword, ...gridResults });
1756
+ }
1757
+ if (keywordResults.length === 1) {
1758
+ const kr = keywordResults[0];
1759
+ return {
1760
+ success: true,
1761
+ data: {
1762
+ target: { name: targetName, place_id: params.placeId },
1763
+ grid_size: `${gridSize}x${gridSize}`,
1764
+ grid_spacing_m: spacingMeters,
1765
+ keyword: kr.keyword,
1766
+ metrics: kr.metrics,
1767
+ grid: kr.grid
1768
+ }
1769
+ };
1770
+ }
1771
+ return {
1772
+ success: true,
1773
+ data: {
1774
+ target: { name: targetName, place_id: params.placeId },
1775
+ grid_size: `${gridSize}x${gridSize}`,
1776
+ grid_spacing_m: spacingMeters,
1777
+ keywords: keywordResults.map((kr) => ({
1778
+ keyword: kr.keyword,
1779
+ metrics: kr.metrics,
1780
+ grid: kr.grid
1781
+ }))
1782
+ }
1783
+ };
1784
+ } catch (error) {
1785
+ return {
1786
+ success: false,
1787
+ error: error instanceof Error ? error.message : "An error occurred during local rank tracking"
1788
+ };
1789
+ }
1790
+ }
1791
+ async scanKeywordGrid(keyword, placeId, gridPoints, spacingMeters) {
1792
+ const concurrency = 5;
1793
+ const gridResults = [];
1794
+ const searchOne = async (point) => {
1795
+ try {
1796
+ const places = await this.newPlacesService.searchText({
1797
+ textQuery: keyword,
1798
+ locationBias: { lat: point.lat, lng: point.lng, radius: spacingMeters / 2 },
1799
+ maxResultCount: 20
1800
+ });
1801
+ const rank = places.findIndex((p) => p.place_id === placeId);
1802
+ const top3 = places.slice(0, 3).map((p) => p.name || "");
1803
+ return {
1804
+ row: point.row,
1805
+ col: point.col,
1806
+ lat: Math.round(point.lat * 1e6) / 1e6,
1807
+ lng: Math.round(point.lng * 1e6) / 1e6,
1808
+ rank: rank >= 0 ? rank + 1 : null,
1809
+ top3
1810
+ };
1811
+ } catch {
1812
+ return {
1813
+ row: point.row,
1814
+ col: point.col,
1815
+ lat: Math.round(point.lat * 1e6) / 1e6,
1816
+ lng: Math.round(point.lng * 1e6) / 1e6,
1817
+ rank: null,
1818
+ top3: []
1819
+ };
1820
+ }
1821
+ };
1822
+ for (let i = 0; i < gridPoints.length; i += concurrency) {
1823
+ const batch = gridPoints.slice(i, i + concurrency);
1824
+ const results = await Promise.all(batch.map(searchOne));
1825
+ gridResults.push(...results);
1826
+ }
1827
+ const rankedPoints = gridResults.filter((r) => r.rank !== null);
1828
+ const totalPoints = gridResults.length;
1829
+ const inTop3 = rankedPoints.filter((r) => r.rank <= 3).length;
1830
+ const arp = rankedPoints.length > 0 ? Math.round(rankedPoints.reduce((sum, r) => sum + r.rank, 0) / rankedPoints.length * 10) / 10 : null;
1831
+ const atrp = Math.round(gridResults.reduce((sum, r) => sum + (r.rank ?? 21), 0) / totalPoints * 10) / 10;
1832
+ const solv = Math.round(inTop3 / totalPoints * 1e3) / 10;
1833
+ return {
1834
+ metrics: {
1835
+ arp,
1836
+ atrp,
1837
+ solv,
1838
+ found_in: `${rankedPoints.length}/${totalPoints}`
1839
+ },
1840
+ grid: gridResults
1841
+ };
1842
+ }
1843
+ };
1844
+
1845
+ // src/index.ts
1846
+ var Logger = {
1847
+ log: (...args) => {
1848
+ console.error("[INFO]", ...args);
1849
+ },
1850
+ error: (...args) => {
1851
+ console.error("[ERROR]", ...args);
1852
+ }
1853
+ };
1854
+
1855
+ export {
1856
+ NewPlacesService,
1857
+ PlacesSearcher,
1858
+ Logger
1859
+ };
1860
+ //# sourceMappingURL=chunk-VCFXCLJF.js.map