fix: Google-Rezensionen auf "Places API (New)" umgestellt
Symptom: dauerhaft REQUEST_DENIED mit "The provided API key is invalid",
obwohl der Schluessel keinerlei Einschraenkung traegt.
Die Meldung fuehrt in die Irre. Der Code rief den alten Endpunkt
maps.googleapis.com/maps/api/place/details/json auf, und Google laesst
die dazugehoerige alte "Places API" in seit Maerz 2025 angelegten
Cloud-Projekten gar nicht mehr aktivieren. Der Fehler betrifft also nicht
den Schluessel, sondern die API — wer der Meldung folgt, sucht
zwangslaeufig an der falschen Stelle. Das README hatte diesen Fall als
"Legacy-Hinweis" bereits vorhergesagt.
Jetzt places.googleapis.com/v1 mit X-Goog-Api-Key im Header (statt des
Schluessels in der Adresszeile, wo er in fremden Zugriffsprotokollen
landet) und einer engen X-Goog-FieldMask — die API rechnet nach
angeforderten Feldern ab.
Die Fehlermeldung im Log nennt jetzt die drei Dinge, an denen es
tatsaechlich liegen kann: API nicht aktiviert, keine Abrechnung am
Projekt, oder ein OAuth-Client statt eines API-Schluessels hinterlegt.
Zwei Verhaltensaenderungen, beide unvermeidbar:
- Die neue API kennt kein reviews_sort=newest; sie liefert bis zu fuenf
Rezensionen in Googles eigener Reihenfolge.
- Rezensionen ohne Namen werden uebersprungen (zuvor nur die ohne
Text): ohne Namen fehlt die von den Nutzungsbedingungen verlangte
Zuordnung zur Quelle.
README und .env.example nennen jetzt alle vier noetigen Schritte und
weisen ausdruecklich darauf hin, dass eine Referrer-Beschraenkung den
Abruf blockiert — er laeuft serverseitig.
Dazu, mit Blick auf mehrere Instanzen: DATABASE_POOL_SIZE ist jetzt
einstellbar (Vorgabe 20) und in der ConfigMap dokumentiert. Der Wert gilt
je Instanz und multipliziert sich mit "replicas" — drei Instanzen belegen
60 der voreingestellt 100 PostgreSQL-Verbindungen.
tsc: 0 Fehler. vitest: 336/336 gruen. eslint: 0 Warnungen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
1 parent
9b10a9f0c5
commit
6639da130e
5 files changed
+160
-68
No files matched your search
+24
-2
@@ -18,7 +18,29 @@ import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
* bei mehreren Instanzen multipliziert sich dieser Wert und will neu bedacht
|
||||
* werden.
|
||||
*/
|
||||
const POOL_SIZE = 20;
|
||||
const DEFAULT_POOL_SIZE = 20;
|
||||
|
||||
/**
|
||||
* Der Wert gilt PRO INSTANZ. Wer die Anwendung auf mehrere Instanzen
|
||||
* hochzieht, multipliziert damit auch die Verbindungen: drei Instanzen mit je
|
||||
* 20 belegen 60 der voreingestellt 100 Verbindungen von PostgreSQL, und
|
||||
* daneben brauchen der Aufraeum-CronJob, die Sicherung und gelegentlich eine
|
||||
* Konsole noch welche. Deshalb einstellbar statt fest verdrahtet — beim
|
||||
* Hochskalieren ist das der Wert, der mitwandern muss.
|
||||
*/
|
||||
function poolSize(): number {
|
||||
const raw = process.env.DATABASE_POOL_SIZE?.trim();
|
||||
if (!raw) return DEFAULT_POOL_SIZE;
|
||||
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||
console.error(
|
||||
`[db] DATABASE_POOL_SIZE="${raw}" ist keine ganze Zahl groesser 0 — es wird mit ${DEFAULT_POOL_SIZE} gearbeitet.`
|
||||
);
|
||||
return DEFAULT_POOL_SIZE;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie lange eine Anfrage auf eine freie Verbindung wartet, bevor sie
|
||||
@@ -52,7 +74,7 @@ export function createDbAdapter(): PrismaPg | PrismaMariaDb {
|
||||
}
|
||||
return new PrismaPg({
|
||||
connectionString,
|
||||
max: POOL_SIZE,
|
||||
max: poolSize(),
|
||||
// Ohne Zeitgrenze wartet eine Anfrage unbegrenzt auf eine freie
|
||||
// Verbindung. Unter Andrang staut sich dann alles lautlos auf, statt dass
|
||||
// irgendwo ein Fehler sichtbar wuerde.
|
||||
|
||||
+104
-57
@@ -12,35 +12,71 @@ export interface GoogleReview {
|
||||
profilePhotoUrl: string | null;
|
||||
}
|
||||
|
||||
/** Google antwortet auch bei Fehlern mit HTTP 200 — die Aussage steckt in "status". */
|
||||
const GOOGLE_TIMEOUT_MS = 5000;
|
||||
|
||||
interface PlacesDetailsResponse {
|
||||
result?: {
|
||||
rating?: number;
|
||||
user_ratings_total?: number;
|
||||
reviews?: {
|
||||
author_name: string;
|
||||
rating: number;
|
||||
text?: string;
|
||||
relative_time_description: string;
|
||||
author_url?: string;
|
||||
profile_photo_url?: string;
|
||||
}[];
|
||||
/**
|
||||
* Nur diese Felder werden angefordert. Die Places API rechnet nach
|
||||
* angeforderten Feldern ab — eine Maske mit "*" waere teurer und lieferte
|
||||
* Daten, die diese Seite nie anzeigt.
|
||||
*/
|
||||
const FIELD_MASK = "rating,userRatingCount,reviews";
|
||||
|
||||
interface PlacesTextV1 {
|
||||
text?: string;
|
||||
}
|
||||
|
||||
interface PlacesReviewV1 {
|
||||
rating?: number;
|
||||
relativePublishTimeDescription?: string;
|
||||
text?: PlacesTextV1;
|
||||
originalText?: PlacesTextV1;
|
||||
authorAttribution?: {
|
||||
displayName?: string;
|
||||
uri?: string;
|
||||
photoUri?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PlacesDetailsV1Response {
|
||||
rating?: number;
|
||||
userRatingCount?: number;
|
||||
reviews?: PlacesReviewV1[];
|
||||
/** Nur im Fehlerfall gesetzt — anders als frueher antwortet die API dann auch mit einem Fehlerstatus. */
|
||||
error?: {
|
||||
code?: number;
|
||||
message?: string;
|
||||
status?: string;
|
||||
};
|
||||
status: string;
|
||||
error_message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft aktuelle Google-Rezensionen fuer den in GOOGLE_PLACE_ID hinterlegten
|
||||
* Google-Maps-Eintrag ab (Places API, Legacy "Place Details"-Endpoint).
|
||||
* Ohne konfigurierte Zugangsdaten wird null zurueckgegeben, sodass die
|
||||
* Startseite auf die statischen Beispiel-Stimmen zurueckfaellt.
|
||||
* Google-Maps-Eintrag ab. Ohne konfigurierte Zugangsdaten wird null
|
||||
* zurueckgegeben, sodass die Startseite auf die statischen Beispiel-Stimmen
|
||||
* zurueckfaellt.
|
||||
*
|
||||
* Setup: Google-Cloud-Projekt mit aktivierter "Places API", API-Key als
|
||||
* GOOGLE_PLACES_API_KEY, die Place-ID des Google-Maps-Eintrags als
|
||||
* GOOGLE_PLACE_ID (zu finden ueber https://developers.google.com/maps/documentation/places/web-service/place-id).
|
||||
* Verwendet wird die "Places API (New)" (places.googleapis.com/v1), NICHT der
|
||||
* alte Endpunkt maps.googleapis.com/maps/api/place/details/json. Das ist kein
|
||||
* Geschmacksurteil: Google laesst die alte "Places API" in seit Maerz 2025
|
||||
* angelegten Cloud-Projekten gar nicht mehr aktivieren. Ein Aufruf des alten
|
||||
* Endpunkts wird dort beantwortet mit
|
||||
*
|
||||
* REQUEST_DENIED — The provided API key is invalid.
|
||||
*
|
||||
* also mit einer Aussage ueber den Schluessel, obwohl der Schluessel in
|
||||
* Ordnung ist und keinerlei Einschraenkung traegt. Wer dieser Meldung folgt,
|
||||
* sucht den Fehler zwangslaeufig an der falschen Stelle.
|
||||
*
|
||||
* Einrichtung (siehe auch README, Abschnitt "Google-Rezensionen"):
|
||||
* 1. In der Google Cloud Console die API "Places API (New)" aktivieren —
|
||||
* nicht die gleichnamige alte, nicht die Maps JavaScript API.
|
||||
* 2. Abrechnung fuer das Projekt aktivieren; ohne sie antwortet auch die
|
||||
* neue API mit einem Berechtigungsfehler.
|
||||
* 3. Unter "Anmeldedaten" einen API-SCHLUESSEL erzeugen. Ein OAuth-Client
|
||||
* ist etwas anderes und funktioniert hier nicht.
|
||||
* 4. Schluessel als GOOGLE_PLACES_API_KEY setzen, die Place-ID als
|
||||
* GOOGLE_PLACE_ID (zu finden ueber
|
||||
* https://developers.google.com/maps/documentation/places/web-service/place-id).
|
||||
*/
|
||||
export async function fetchGoogleReviews(): Promise<{
|
||||
reviews: GoogleReview[];
|
||||
@@ -51,60 +87,71 @@ export async function fetchGoogleReviews(): Promise<{
|
||||
const placeId = process.env.GOOGLE_PLACE_ID;
|
||||
if (!apiKey || !placeId) return null;
|
||||
|
||||
const url = new URL("https://maps.googleapis.com/maps/api/place/details/json");
|
||||
url.searchParams.set("place_id", placeId);
|
||||
// "reviews" (Plural!) — mit "review" antwortet Google mit INVALID_REQUEST,
|
||||
// und weil jeder Fehlerfall hier still auf die Beispiel-Stimmen
|
||||
// zurueckfaellt, sah die Startseite dabei voellig normal aus. Genau so
|
||||
// blieb der Tippfehler unbemerkt.
|
||||
url.searchParams.set("fields", "reviews,rating,user_ratings_total");
|
||||
url.searchParams.set("language", "de");
|
||||
url.searchParams.set("reviews_sort", "newest");
|
||||
url.searchParams.set("key", apiKey);
|
||||
// Die Place-ID wird mal als "ChIJ..." und mal als "places/ChIJ..." notiert —
|
||||
// der Pfad braucht sie genau einmal mit Praefix.
|
||||
const resourceName = placeId.startsWith("places/") ? placeId : `places/${placeId}`;
|
||||
|
||||
const url = new URL(`https://places.googleapis.com/v1/${resourceName}`);
|
||||
url.searchParams.set("languageCode", "de");
|
||||
|
||||
try {
|
||||
// 1 Stunde Cache: haelt die Seite schnell und reduziert API-Kosten,
|
||||
// ohne die Rezensionen spuerbar veralten zu lassen.
|
||||
// 1 Stunde Cache: haelt die Seite schnell und reduziert API-Kosten, ohne
|
||||
// die Rezensionen spuerbar veralten zu lassen.
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
// Der Schluessel gehoert bei dieser API in den Header, nicht in die
|
||||
// Adresszeile — so steht er nicht in fremden Zugriffsprotokollen.
|
||||
"X-Goog-Api-Key": apiKey,
|
||||
"X-Goog-FieldMask": FIELD_MASK,
|
||||
},
|
||||
next: { revalidate: 3600 },
|
||||
// Ohne Zeitlimit haengt die Startseite an einer nicht antwortenden
|
||||
// fremden API — dieselbe Absicherung wie beim Passwort-Leak-Abgleich.
|
||||
signal: AbortSignal.timeout(GOOGLE_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.warn(`[google-reviews] Places API antwortete mit HTTP ${response.status}.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as PlacesDetailsResponse;
|
||||
if (data.status !== "OK" || !data.result) {
|
||||
// Laut melden statt still zurueckfallen: "REQUEST_DENIED" (Schluessel
|
||||
// ungueltig oder Places API nicht aktiviert), "INVALID_REQUEST"
|
||||
// (Parameter falsch) und "NOT_FOUND" (Place-ID stimmt nicht) sehen auf
|
||||
// der Startseite alle gleich aus — naemlich nach den Beispiel-Stimmen.
|
||||
const data = (await response.json().catch(() => null)) as PlacesDetailsV1Response | null;
|
||||
|
||||
if (!response.ok || data?.error) {
|
||||
// Laut melden statt still zurueckfallen: ein falscher Schluessel, eine
|
||||
// nicht aktivierte API und eine unbekannte Place-ID sehen auf der
|
||||
// Startseite alle gleich aus — naemlich nach den Beispiel-Stimmen.
|
||||
const detail = data?.error?.message ?? `HTTP ${response.status}`;
|
||||
console.warn(
|
||||
`[google-reviews] Places API meldet "${data.status}"${data.error_message ? `: ${data.error_message}` : ""}.`
|
||||
`[google-reviews] Places API (New) hat den Abruf abgelehnt: ${detail}. Zu pruefen: Ist "Places API (New)" im Google-Cloud-Projekt aktiviert, ist die Abrechnung aktiv, und ist GOOGLE_PLACES_API_KEY ein API-Schluessel (kein OAuth-Client)?`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (!data) {
|
||||
console.warn("[google-reviews] Antwort der Places API war kein gueltiges JSON.");
|
||||
return null;
|
||||
}
|
||||
|
||||
const reviews: GoogleReview[] = (data.result.reviews ?? [])
|
||||
const reviews: GoogleReview[] = (data.reviews ?? [])
|
||||
.map((review) => {
|
||||
// "text" ist die (ggf. uebersetzte) Fassung in languageCode,
|
||||
// "originalText" die Originalsprache. Fehlt die Uebersetzung, ist das
|
||||
// Original immer noch besser als gar kein Zitat.
|
||||
const text = (review.text?.text ?? review.originalText?.text ?? "").trim();
|
||||
const author = review.authorAttribution?.displayName?.trim() ?? "";
|
||||
return {
|
||||
author,
|
||||
rating: review.rating ?? 0,
|
||||
text,
|
||||
relativeTime: review.relativePublishTimeDescription ?? "",
|
||||
authorUrl: review.authorAttribution?.uri ?? null,
|
||||
profilePhotoUrl: review.authorAttribution?.photoUri ?? null,
|
||||
};
|
||||
})
|
||||
// Rezensionen ohne Text sind reine Sternebewertungen — als Zitat auf der
|
||||
// Startseite waeren sie eine leere Sprechblase.
|
||||
.filter((r) => r.text?.trim())
|
||||
.map((r) => ({
|
||||
author: r.author_name,
|
||||
rating: r.rating,
|
||||
text: r.text!.trim(),
|
||||
relativeTime: r.relative_time_description,
|
||||
authorUrl: r.author_url ?? null,
|
||||
profilePhotoUrl: r.profile_photo_url ?? null,
|
||||
}));
|
||||
// Startseite waeren sie eine leere Sprechblase. Ohne Namen fehlt zudem
|
||||
// die von den Nutzungsbedingungen verlangte Zuordnung zur Quelle.
|
||||
.filter((review) => review.text.length > 0 && review.author.length > 0);
|
||||
|
||||
return {
|
||||
reviews,
|
||||
rating: data.result.rating ?? null,
|
||||
totalCount: data.result.user_ratings_total ?? null,
|
||||
rating: data.rating ?? null,
|
||||
totalCount: data.userRatingCount ?? null,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
// Netzwerk-/API-Fehler duerfen die Startseite nicht zum Absturz bringen —
|
||||
|
||||
Reference in new issue
Block a user