fix(garden): replace perenual with openplantbook for species lookup

Perenual's free tier does not serve care instructions. OpenPlantBook
provides watering, sunlight, and other care data at no cost.

- OAuth2 client_credentials token fetch with module-level cache
- Search via /api/v1/plant/search/, detail via /api/v1/plant/detail/{pid}/?include=care
- SpeciesSuggestion shape unchanged; no frontend or schema changes required
- Env vars: OPENPLANTBOOK_CLIENT_ID + OPENPLANTBOOK_CLIENT_SECRET (replaces PERENUAL_API_KEY)
This commit is contained in:
ginnoir
2026-06-02 03:24:20 -05:00
parent e0b423aad5
commit 32e97b0677
2 changed files with 79 additions and 46 deletions
+3 -2
View File
@@ -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=
+76 -44
View File
@@ -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<string | null> {
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<string, unknown>): 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<string, unknown> | 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<SpeciesSuggestion[]> {
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<string, unknown>));
const pids = json.results
.map((r) => String((r as Record<string, unknown>).pid ?? ""))
.filter(Boolean);
await Promise.allSettled(
results.map((r) =>
db
.insert(gardenSpeciesCache)
.values({
speciesId: r.id,
data: r as unknown as Record<string, unknown>,
cachedAt: new Date(),
})
.onConflictDoUpdate({
target: gardenSpeciesCache.speciesId,
set: { data: r as unknown as Record<string, unknown>, cachedAt: new Date() },
}),
),
);
return results;
const details = await Promise.allSettled(pids.map((pid) => getSpeciesById(pid)));
return details
.filter(
(r): r is PromiseFulfilledResult<SpeciesSuggestion> =>
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<SpeciesSuggestion | nu
if (cached) return cached.data as unknown as SpeciesSuggestion;
const apiKey = process.env.PERENUAL_API_KEY;
if (!apiKey) return null;
const token = await getToken();
if (!token) return null;
try {
const url = `${BASE_URL}/api/species/details/${encodeURIComponent(id)}?key=${encodeURIComponent(apiKey)}`;
const res = await fetch(url, { cache: "no-store" });
const url = `${BASE_URL}/plant/detail/${encodeURIComponent(id)}/?include=care`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
if (!res.ok) return null;
const item = (await res.json()) as Record<string, unknown>;