From 4e62b48ef06b75d55b7ebd3c78b5c9a10f959dbb Mon Sep 17 00:00:00 2001 From: CabLate Date: Sat, 21 Mar 2026 13:22:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20dual-sort=20reviews=20=E2=80=94=20merge?= =?UTF-8?q?=20relevant=20+=20newest=20for=20~10=20reviews=20per=20place?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google Places API (New) returns max 5 reviews sorted by relevance. This change fetches an additional 5 newest reviews via Legacy Place Details API (which supports reviews_sort=newest), then merges and deduplicates by author+timestamp. Result: place_details now returns ~10 reviews instead of 5, with both high-quality relevant reviews and the most recent ones — enabling better AI context classification and trend detection. Also fixes Prettier formatting inconsistencies from PR #63 squash merge. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/services/NewPlacesService.ts | 58 +++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/src/services/NewPlacesService.ts b/src/services/NewPlacesService.ts index f119495..5b97c56 100644 --- a/src/services/NewPlacesService.ts +++ b/src/services/NewPlacesService.ts @@ -3,6 +3,7 @@ import { Logger } from "../index.js"; export class NewPlacesService { private client: PlacesClient; + private readonly apiKey: string; private readonly defaultLanguage: string = "en"; private readonly placeFieldMask: string = [ "displayName", @@ -78,11 +79,10 @@ export class NewPlacesService { "places.priceLevel", ].join(","); constructor(apiKey?: string) { - this.client = new PlacesClient({ - apiKey: apiKey || process.env.GOOGLE_MAPS_API_KEY || "", - }); + this.apiKey = apiKey || process.env.GOOGLE_MAPS_API_KEY || ""; + this.client = new PlacesClient({ apiKey: this.apiKey }); - if (!apiKey && !process.env.GOOGLE_MAPS_API_KEY) { + if (!this.apiKey) { throw new Error("Google Maps API Key is required"); } } @@ -213,13 +213,61 @@ export class NewPlacesService { } ); - return this.transformPlaceResponse(place); + // Fetch newest reviews via REST (gRPC SDK doesn't support reviews_sort) + const newestReviews = await this.fetchNewestReviews(placeId); + + // Merge: relevant (default) + newest, deduplicate by author+time + const allReviews = this.mergeReviews(place?.reviews || [], newestReviews); + const merged = { ...place, reviews: allReviews }; + + return this.transformPlaceResponse(merged); } catch (error: any) { Logger.error("Error in getPlaceDetails (New API):", error); throw new Error(`Failed to get place details for ${placeId}: ${this.extractErrorMessage(error)}`); } } + private async fetchNewestReviews(placeId: string): Promise { + try { + // Use Legacy Place Details API which supports reviews_sort=newest + // (Places API New does not support this parameter) + const url = `https://maps.googleapis.com/maps/api/place/details/json?place_id=${placeId}&fields=reviews&reviews_sort=newest&language=${this.defaultLanguage}&key=${this.apiKey}`; + const response = await fetch(url); + if (!response.ok) return []; + const data = await response.json(); + if (data.status !== "OK") return []; + // Transform Legacy format to match New API format for mergeReviews + return (data.result?.reviews || []).map((r: any) => ({ + rating: r.rating, + text: { text: r.text || "", languageCode: r.language || null }, + publishTime: { seconds: r.time }, + authorAttribution: { displayName: r.author_name || "" }, + })); + } catch { + return []; + } + } + + private mergeReviews(relevant: any[], newest: any[]): any[] { + const seen = new Set(); + const merged: any[] = []; + + for (const review of [...relevant, ...newest]) { + const author = review?.authorAttribution?.displayName || ""; + const time = String(review?.publishTime?.seconds || ""); + const key = `${author}|${time}`; + if (!key || key === "|") { + merged.push(review); + continue; + } + if (seen.has(key)) continue; + seen.add(key); + merged.push(review); + } + + return merged; + } + private transformSearchResult(place: any) { return { name: place.displayName?.text || "",