import type { Attribution, BackgroundAsset, PhotoProvider, PhotoProviderStatus, StockPhoto, } from "../types"; import { PhotoProviderError } from "./errors"; /** * Pexels 搜索。与 unsplash.ts 一样归一到 `PhotoProvider` 接口。 * * - API Key 从 `import.meta.env.VITE_PEXELS_KEY` 读(请求头 `Authorization: `, * 没有 "Bearer " 前缀;Unsplash 才是 `Client-ID` 前缀)。 * - 共享错误类 `PhotoProviderError` 在 `errors.ts`(不依赖任何兄弟模块的叶子), * 从那里 import,避免和 providers.ts 的注册表互相引成环。 * - **没配 key 时不崩**:静默处于 "missing" 态;`search()` 抛 `PhotoProviderError`。 * - 速率配额比 Unsplash demo key 高得多(200/时 vs 50/时),dev 期主用这家, * 所以 `PHOTO_PROVIDERS` 里它排在前面(providers.ts 组装)。 * - Pexels 的署名是「建议」而非「强制」,也没有下载统计端点—— * `notifyDownload` 不实现(接口里它是可选方法,调用方无条件调即可)。 */ /** 纯函数解析:按官方文档形状构造的响应样本 → 归一化 StockPhoto[]。Node 侧可断言。 */ export function parsePexelsResponse(json: unknown): StockPhoto[] { const root = toRecord(json); if (!root || !Array.isArray(root.photos)) return []; const out: StockPhoto[] = []; for (const item of root.photos) { const photo = toStockPhoto(toRecord(item)); if (photo) out.push(photo); } return out; } export const pexelsProvider: PhotoProvider = { id: "pexels", label: "Pexels", keyUrl: "https://www.pexels.com/api/new/", status(): PhotoProviderStatus { return readKey() ? "ready" : "missing"; }, search: async (query, page) => { const key = readKey(); if (!key) throw new PhotoProviderError("未配置 Pexels API Key(VITE_PEXELS_KEY)"); const q = query.trim(); if (!q) return []; return fetchPexels(q, page, key); }, toAsset: toStockAsset, }; /** 面板能直接用:一条归一化结果 → 一份可进画布的 BackgroundAsset。 */ function toStockAsset(photo: StockPhoto): BackgroundAsset { const attribution: Attribution = { text: `Photo by ${photo.authorName} on Pexels`, url: photo.pageUrl, }; return { kind: "stock", src: photo.fullUrl, width: photo.width, height: photo.height, attribution, }; } /* ---------------------------------------------------------------- 内部 */ function readKey(): string { // import.meta.env 只保证在 Vite 里存在。用可选链兜底,Node/Bun/静态产物都安全。 const meta = import.meta as { env?: { VITE_PEXELS_KEY?: unknown } }; const raw = meta.env?.VITE_PEXELS_KEY; return typeof raw === "string" ? raw.trim() : ""; } function toRecord(v: unknown): Record | null { return v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : null; } // 字段照官方文档形状防御式取值;界面用不到的字段(photographer_url / avg_color / // liked / src.portrait|landscape|tiny 等)一律不解析——不确定的字段不编造。 /** 一张 Pexels 原始对象 → StockPhoto。缺 id / 图片地址的结果没法用,返回 null。 */ function toStockPhoto(raw: Record | null): StockPhoto | null { if (!raw) return null; const id = typeof raw.id === "number" ? String(raw.id) : undefined; const url = typeof raw.url === "string" ? raw.url : undefined; const photographer = typeof raw.photographer === "string" ? raw.photographer : ""; const width = typeof raw.width === "number" ? raw.width : 0; const height = typeof raw.height === "number" ? raw.height : 0; const alt = typeof raw.alt === "string" ? raw.alt.trim() || null : null; const src = toRecord(raw.src); const s = (k: string): string => { const v = src?.[k]; return typeof v === "string" ? v : ""; }; if (!id || !url) return null; const original = s("original"); const large2x = s("large2x"); const large = s("large"); const medium = s("medium"); const small = s("small"); if (!large2x && !large && !original) return null; // 一张图连大图档都没有,没法装进画布 return { id, provider: "pexels", width, height, thumbUrl: medium || small || large2x || large, // 装进画布用约 1600px 的档,不要原图(原图动辄 6000px)。 // Pexels 的档位是宽档:large2x ≈ 1880px,large ≈ 940px,按 brief 优先 large2x。 fullUrl: large2x || large || original, pageUrl: url, authorName: photographer || "Pexels 用户", alt, // Pexels 没有下载统计端点,downloadLocation 不填。 }; } /** 实际请求。错误路径统一抛 PhotoProviderError。 */ async function fetchPexels(query: string, page: number, key: string): Promise { const url = `https://api.pexels.com/v1/search?query=${encodeURIComponent(query)}&page=${page}&per_page=15`; let res: Response; try { res = await fetch(url, { headers: { Authorization: key } }); } catch { throw new PhotoProviderError("连不上 Pexels(网络或跨域限制)"); } if (!res.ok) { if (res.status === 401) throw new PhotoProviderError("Pexels API Key 无效,检查 .env.local"); if (res.status === 403 || res.status === 429) throw new PhotoProviderError("Pexels 请求被限流,等一会儿再试"); // 非预期状态:响应体里可能有官方 error 字段,能读就读出来给用户看。 let detail = ""; try { const body = toRecord((await res.json()) as unknown); detail = typeof body?.error === "string" ? `:${body.error}` : ""; } catch { /* 读不出就算了,用状态码兜底 */ } throw new PhotoProviderError(`Pexels 返回 ${res.status}${detail}`); } const data = (await res.json()) as unknown; return parsePexelsResponse(data); }