feat: Google-Rezensionen ueber Places API (New) abrufen
Muster wie Wabenhain (Feldmaske, 1 h Cache, 5 s Timeout, Schluessel im Header). Ohne Konfiguration oder bei Fehler null, keine Ersatz-Stimmen. Keine Profilbilder (kein IP-Transfer der Besucherinnen an Google), Quellenlinks nur als https. Neue Variablen GOOGLE_PLACES_API_KEY und GOOGLE_PLACE_ID in .env.example und Secret-Vorlage. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
66247cfbf9
commit
b4eafbe433
3 files changed
+112
No files matched your search
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Echte Google-Rezensionen ueber die "Places API (New)" (gleiches Muster wie
|
||||
* im Wabenhain-Projekt).
|
||||
*
|
||||
* - Ohne GOOGLE_PLACES_API_KEY/GOOGLE_PLACE_ID oder bei Fehlern: null. Die
|
||||
* Seite blendet den Abschnitt dann aus — es gibt bewusst KEINE
|
||||
* Beispiel-Stimmen als Ersatz (keine erfundenen Testimonials).
|
||||
* - Profilbilder werden nicht geladen: sie kaemen direkt von Google-Servern
|
||||
* und wuerden die IP-Adressen der Besucherinnen dorthin uebertragen.
|
||||
* - Zuordnung zur Quelle (Nutzungsbedingungen): Autorenname, Link zur
|
||||
* Rezension und zum Google-Maps-Eintrag.
|
||||
*
|
||||
* Einrichtung: in der Google Cloud Console "Places API (New)" aktivieren,
|
||||
* Abrechnung aktivieren, API-Schluessel erzeugen (kein OAuth-Client) und
|
||||
* GOOGLE_PLACES_API_KEY sowie GOOGLE_PLACE_ID setzen.
|
||||
*/
|
||||
export type GoogleReview = {
|
||||
author: string;
|
||||
rating: number;
|
||||
text: string;
|
||||
relativeTime: string;
|
||||
authorUrl: string | null;
|
||||
};
|
||||
|
||||
export type GoogleReviewSummary = {
|
||||
reviews: GoogleReview[];
|
||||
rating: number | null;
|
||||
totalCount: number | null;
|
||||
mapsUrl: string | null;
|
||||
};
|
||||
|
||||
const TIMEOUT_MS = 5000;
|
||||
// Abgerechnet wird nach angeforderten Feldern — nur das Noetige anfordern.
|
||||
const FIELD_MASK = "rating,userRatingCount,reviews,googleMapsUri";
|
||||
|
||||
type PlacesResponse = {
|
||||
rating?: number;
|
||||
userRatingCount?: number;
|
||||
googleMapsUri?: string;
|
||||
reviews?: {
|
||||
rating?: number;
|
||||
relativePublishTimeDescription?: string;
|
||||
text?: { text?: string };
|
||||
originalText?: { text?: string };
|
||||
authorAttribution?: { displayName?: string; uri?: string };
|
||||
}[];
|
||||
error?: { message?: string };
|
||||
};
|
||||
|
||||
function safeHttpsUrl(value: string | undefined): string | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "https:" ? url.toString() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGoogleReviews(): Promise<GoogleReviewSummary | null> {
|
||||
const apiKey = process.env.GOOGLE_PLACES_API_KEY;
|
||||
const placeId = process.env.GOOGLE_PLACE_ID;
|
||||
if (!apiKey || !placeId) return null;
|
||||
|
||||
const resource = placeId.startsWith("places/") ? placeId : `places/${placeId}`;
|
||||
const url = new URL(`https://places.googleapis.com/v1/${encodeURI(resource)}`);
|
||||
url.searchParams.set("languageCode", "de");
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
// Schluessel im Header, nicht in der URL (keine fremden Zugriffsprotokolle).
|
||||
headers: { "X-Goog-Api-Key": apiKey, "X-Goog-FieldMask": FIELD_MASK },
|
||||
// 1 Stunde Cache: schnell und guenstig, ohne spuerbar zu veralten.
|
||||
next: { revalidate: 3600 },
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
const data = (await response.json().catch(() => null)) as PlacesResponse | null;
|
||||
if (!response.ok || !data || data.error) {
|
||||
console.warn(`[google-reviews] Abruf abgelehnt: ${data?.error?.message ?? `HTTP ${response.status}`}`);
|
||||
return null;
|
||||
}
|
||||
const reviews = (data.reviews ?? [])
|
||||
.map((r) => ({
|
||||
author: r.authorAttribution?.displayName?.trim() ?? "",
|
||||
rating: Math.max(0, Math.min(5, Math.round(r.rating ?? 0))),
|
||||
text: (r.text?.text ?? r.originalText?.text ?? "").trim(),
|
||||
relativeTime: r.relativePublishTimeDescription ?? "",
|
||||
authorUrl: safeHttpsUrl(r.authorAttribution?.uri),
|
||||
}))
|
||||
// Ohne Text keine Stimme, ohne Namen keine Zuordnung zur Quelle.
|
||||
.filter((r) => r.text.length > 0 && r.author.length > 0);
|
||||
if (reviews.length === 0) return null;
|
||||
return {
|
||||
reviews,
|
||||
rating: typeof data.rating === "number" ? data.rating : null,
|
||||
totalCount: typeof data.userRatingCount === "number" ? data.userRatingCount : null,
|
||||
mapsUrl: safeHttpsUrl(data.googleMapsUri),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
console.warn(`[google-reviews] Abruf fehlgeschlagen: ${error instanceof Error ? error.message : "unbekannt"}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user