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
@@ -53,3 +53,9 @@ MEDIA_DIR=./media
|
|||||||
# Datenschutzkonzept fuer externe Videos: none | youtube-nocookie | vimeo
|
# Datenschutzkonzept fuer externe Videos: none | youtube-nocookie | vimeo
|
||||||
# Externe Player laden nur nach Klick und ausdruecklicher Zustimmung.
|
# Externe Player laden nur nach Klick und ausdruecklicher Zustimmung.
|
||||||
EXTERNAL_VIDEO_PROVIDER=none
|
EXTERNAL_VIDEO_PROVIDER=none
|
||||||
|
|
||||||
|
# --- Google-Rezensionen (optional) ------------------------------------------
|
||||||
|
# "Places API (New)" aktivieren, Abrechnung an, API-Schluessel (kein OAuth).
|
||||||
|
# Ohne Werte wird der Rezensionen-Abschnitt ausgeblendet.
|
||||||
|
GOOGLE_PLACES_API_KEY=
|
||||||
|
GOOGLE_PLACE_ID=
|
||||||
@@ -16,6 +16,9 @@ stringData:
|
|||||||
SMTP_HOST: "smtp.example.org"
|
SMTP_HOST: "smtp.example.org"
|
||||||
SMTP_USER: "benutzer"
|
SMTP_USER: "benutzer"
|
||||||
SMTP_PASSWORD: "passwort"
|
SMTP_PASSWORD: "passwort"
|
||||||
|
# Optional: Google-Rezensionen (Places API New). Leer lassen = Abschnitt aus.
|
||||||
|
GOOGLE_PLACES_API_KEY: ""
|
||||||
|
GOOGLE_PLACE_ID: ""
|
||||||
---
|
---
|
||||||
# Zugang zur SMB-Freigabe (fuer 03-storage.yaml)
|
# Zugang zur SMB-Freigabe (fuer 03-storage.yaml)
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
|
|||||||
@@ -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