diff --git a/.env.example b/.env.example index 67d1d71..2cbdbaa 100644 --- a/.env.example +++ b/.env.example @@ -40,5 +40,6 @@ MINIO_ROOT_USER=famapp MINIO_ROOT_PASSWORD=changeme MINIO_BUCKET=garden -# Perenual plant species API (https://perenual.com — free tier available) -PERENUAL_API_KEY= +# OpenPlantBook plant species API (https://open.plantbook.io — free account required) +OPENPLANTBOOK_CLIENT_ID= +OPENPLANTBOOK_CLIENT_SECRET= diff --git a/src/modules/garden/server/species-lookup.ts b/src/modules/garden/server/species-lookup.ts index abdaa5a..289583f 100644 --- a/src/modules/garden/server/species-lookup.ts +++ b/src/modules/garden/server/species-lookup.ts @@ -13,60 +13,89 @@ export type SpeciesSuggestion = { }; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; -const BASE_URL = "https://perenual.com"; +const BASE_URL = "https://open.plantbook.io/api/v1"; + +type TokenCache = { access_token: string; expiresAt: number } | null; +let tokenCache: TokenCache = null; + +async function getToken(): Promise { + const clientId = process.env.OPENPLANTBOOK_CLIENT_ID; + const clientSecret = process.env.OPENPLANTBOOK_CLIENT_SECRET; + if (!clientId || !clientSecret) return null; + + if (tokenCache && Date.now() < tokenCache.expiresAt - 60_000) { + return tokenCache.access_token; + } + + try { + const res = await fetch(`${BASE_URL}/token/`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "client_credentials", + client_id: clientId, + client_secret: clientSecret, + }), + cache: "no-store", + }); + if (!res.ok) throw new Error(`token request failed: ${res.status}`); + const json = (await res.json()) as { access_token: string; expires_in: number }; + tokenCache = { + access_token: json.access_token, + expiresAt: Date.now() + json.expires_in * 1000, + }; + return tokenCache.access_token; + } catch (err) { + console.error("[species-lookup] getToken failed", err); + return null; + } +} + +function parseSunlight(value: unknown): string[] { + if (Array.isArray(value)) return value.map(String); + if (typeof value === "string" && value) return value.split(", "); + return []; +} function parseItem(item: Record): SpeciesSuggestion { - const rawNames = item.scientific_name; - const scientificName = Array.isArray(rawNames) && rawNames.length > 0 ? String(rawNames[0]) : ""; - - const sunlight = Array.isArray(item.sunlight) ? item.sunlight.map(String) : []; - - const defaultImage = item.default_image as Record | null | undefined; - const rawImageUrl = defaultImage?.regular_url ?? defaultImage?.medium_url ?? null; - return { - id: String(item.id), - common_name: String(item.common_name ?? ""), - scientific_name: scientificName, + id: String(item.pid ?? ""), + common_name: String(item.alias ?? ""), + scientific_name: String(item.display_pid ?? ""), watering: String(item.watering ?? ""), - sunlight, - cycle: String(item.cycle ?? ""), - default_image_url: rawImageUrl ? String(rawImageUrl) : null, + sunlight: parseSunlight(item.sunlight), + cycle: "", + default_image_url: item.image_url ? String(item.image_url) : null, }; } export async function searchSpecies(query: string): Promise { - const apiKey = process.env.PERENUAL_API_KEY; - if (!apiKey) return []; if (!query.trim()) return []; + const token = await getToken(); + if (!token) return []; try { - const url = `${BASE_URL}/api/species-list?key=${encodeURIComponent(apiKey)}&q=${encodeURIComponent(query)}`; - const res = await fetch(url, { cache: "no-store" }); - if (!res.ok) throw new Error(`Perenual API error: ${res.status}`); + const url = `${BASE_URL}/plant/search/?alias=${encodeURIComponent(query)}&limit=20`; + const res = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + }); + if (!res.ok) throw new Error(`OpenPlantBook search error: ${res.status}`); - const json = (await res.json()) as { data?: unknown[] }; - if (!Array.isArray(json.data)) return []; + const json = (await res.json()) as { results?: unknown[] }; + if (!Array.isArray(json.results)) return []; - const results = json.data.map((item) => parseItem(item as Record)); + const pids = json.results + .map((r) => String((r as Record).pid ?? "")) + .filter(Boolean); - await Promise.allSettled( - results.map((r) => - db - .insert(gardenSpeciesCache) - .values({ - speciesId: r.id, - data: r as unknown as Record, - cachedAt: new Date(), - }) - .onConflictDoUpdate({ - target: gardenSpeciesCache.speciesId, - set: { data: r as unknown as Record, cachedAt: new Date() }, - }), - ), - ); - - return results; + const details = await Promise.allSettled(pids.map((pid) => getSpeciesById(pid))); + return details + .filter( + (r): r is PromiseFulfilledResult => + r.status === "fulfilled" && r.value !== null, + ) + .map((r) => r.value); } catch (err) { console.error("[species-lookup] searchSpecies failed", err); return []; @@ -83,12 +112,15 @@ export async function getSpeciesById(id: string): Promise;