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:
+3
-2
@@ -40,5 +40,6 @@ MINIO_ROOT_USER=famapp
|
|||||||
MINIO_ROOT_PASSWORD=changeme
|
MINIO_ROOT_PASSWORD=changeme
|
||||||
MINIO_BUCKET=garden
|
MINIO_BUCKET=garden
|
||||||
|
|
||||||
# Perenual plant species API (https://perenual.com — free tier available)
|
# OpenPlantBook plant species API (https://open.plantbook.io — free account required)
|
||||||
PERENUAL_API_KEY=
|
OPENPLANTBOOK_CLIENT_ID=
|
||||||
|
OPENPLANTBOOK_CLIENT_SECRET=
|
||||||
|
|||||||
@@ -13,60 +13,89 @@ export type SpeciesSuggestion = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
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 {
|
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 {
|
return {
|
||||||
id: String(item.id),
|
id: String(item.pid ?? ""),
|
||||||
common_name: String(item.common_name ?? ""),
|
common_name: String(item.alias ?? ""),
|
||||||
scientific_name: scientificName,
|
scientific_name: String(item.display_pid ?? ""),
|
||||||
watering: String(item.watering ?? ""),
|
watering: String(item.watering ?? ""),
|
||||||
sunlight,
|
sunlight: parseSunlight(item.sunlight),
|
||||||
cycle: String(item.cycle ?? ""),
|
cycle: "",
|
||||||
default_image_url: rawImageUrl ? String(rawImageUrl) : null,
|
default_image_url: item.image_url ? String(item.image_url) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchSpecies(query: string): Promise<SpeciesSuggestion[]> {
|
export async function searchSpecies(query: string): Promise<SpeciesSuggestion[]> {
|
||||||
const apiKey = process.env.PERENUAL_API_KEY;
|
|
||||||
if (!apiKey) return [];
|
|
||||||
if (!query.trim()) return [];
|
if (!query.trim()) return [];
|
||||||
|
const token = await getToken();
|
||||||
|
if (!token) return [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `${BASE_URL}/api/species-list?key=${encodeURIComponent(apiKey)}&q=${encodeURIComponent(query)}`;
|
const url = `${BASE_URL}/plant/search/?alias=${encodeURIComponent(query)}&limit=20`;
|
||||||
const res = await fetch(url, { cache: "no-store" });
|
const res = await fetch(url, {
|
||||||
if (!res.ok) throw new Error(`Perenual API error: ${res.status}`);
|
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[] };
|
const json = (await res.json()) as { results?: unknown[] };
|
||||||
if (!Array.isArray(json.data)) return [];
|
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(
|
const details = await Promise.allSettled(pids.map((pid) => getSpeciesById(pid)));
|
||||||
results.map((r) =>
|
return details
|
||||||
db
|
.filter(
|
||||||
.insert(gardenSpeciesCache)
|
(r): r is PromiseFulfilledResult<SpeciesSuggestion> =>
|
||||||
.values({
|
r.status === "fulfilled" && r.value !== null,
|
||||||
speciesId: r.id,
|
)
|
||||||
data: r as unknown as Record<string, unknown>,
|
.map((r) => r.value);
|
||||||
cachedAt: new Date(),
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: gardenSpeciesCache.speciesId,
|
|
||||||
set: { data: r as unknown as Record<string, unknown>, cachedAt: new Date() },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return results;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[species-lookup] searchSpecies failed", err);
|
console.error("[species-lookup] searchSpecies failed", err);
|
||||||
return [];
|
return [];
|
||||||
@@ -83,12 +112,15 @@ export async function getSpeciesById(id: string): Promise<SpeciesSuggestion | nu
|
|||||||
|
|
||||||
if (cached) return cached.data as unknown as SpeciesSuggestion;
|
if (cached) return cached.data as unknown as SpeciesSuggestion;
|
||||||
|
|
||||||
const apiKey = process.env.PERENUAL_API_KEY;
|
const token = await getToken();
|
||||||
if (!apiKey) return null;
|
if (!token) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `${BASE_URL}/api/species/details/${encodeURIComponent(id)}?key=${encodeURIComponent(apiKey)}`;
|
const url = `${BASE_URL}/plant/detail/${encodeURIComponent(id)}/?include=care`;
|
||||||
const res = await fetch(url, { cache: "no-store" });
|
const res = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
|
|
||||||
const item = (await res.json()) as Record<string, unknown>;
|
const item = (await res.json()) as Record<string, unknown>;
|
||||||
|
|||||||
Reference in New Issue
Block a user