src / image_search / providers / PexelsImageProvider.ts
src / image_search / providers / PexelsImageProvider.ts
import { HTTPClient } from "../../http/HTTPClient";
import { KeyAuthOrQuotaError, NoAPIKeysError, ProviderError } from "../../models/errors";
import { SearchOptions } from "../../types/search";
import { KeyManager } from "../../utils/KeyManager";
import { ImageSearchResult } from "../models";
import { BaseImageProvider } from "./BaseImageProvider";
export const PEXELS_SEARCH_URL = "https://api.pexels.com/v1/search";
const ROTATION_STATUS_CODES = [401, 403, 429];
export interface PexelsImageProviderOptions {
keys?: string[];
keyManager?: KeyManager;
httpClient?: HTTPClient;
httpTimeoutSeconds?: number;
cooldownHours?: number;
}
/**
* Pexels image search provider implementation.
* Queries Pexels v1 Search API endpoint and normalizes photo objects into ImageSearchResult format.
* Requires PEXELS_KEYS configuration and supports multi-key rotation via KeyManager.
*/
export class PexelsImageProvider extends BaseImageProvider {
public static readonly PROVIDER_NAME = "pexels";
private keyManager: KeyManager;
constructor(options: PexelsImageProviderOptions = {}) {
super({
providerName: PexelsImageProvider.PROVIDER_NAME,
httpClient: options.httpClient,
httpTimeoutSeconds: options.httpTimeoutSeconds,
});
this.keyManager =
options.keyManager ??
new KeyManager({
providerName: PexelsImageProvider.PROVIDER_NAME,
keys: options.keys,
defaultCooldownHours: options.cooldownHours ?? 24.0,
});
}
public override setKeys(keys: string[]): void {
this.keyManager.setKeys(keys);
}
/**
* Execute image search using Pexels Search API endpoint with automatic key rotation.
*
* @param query Search query string.
* @param options Search options (numResults, page, extra kwargs).
* @returns List of normalized ImageSearchResult objects.
* @throws NoAPIKeysError if no API keys are configured for Pexels.
* @throws ProviderError if all API keys fail authentication/quota or request fails.
*/
public async searchImages(
query: string,
options: SearchOptions = {}
): Promise<ImageSearchResult[]> {
if (!query || !query.trim()) {
return [];
}
const cleanQuery = query.trim();
const totalKeys = this.keyManager.keyCount();
if (totalKeys === 0) {
throw new NoAPIKeysError(`No API keys configured for provider '${this.providerName}'`);
}
let lastError: Error | null = null;
for (let attempt = 0; attempt < totalKeys; attempt++) {
const activeKey = this.keyManager.currentKey();
try {
const results = await this.doSearch(activeKey, cleanQuery, options);
this.keyManager.markKeySuccess(activeKey);
return results;
} catch (err: unknown) {
if (err instanceof KeyAuthOrQuotaError) {
lastError = err as Error;
this.keyManager.markKeyExhausted(activeKey, (err as KeyAuthOrQuotaError).retryAfterSeconds);
if (attempt < totalKeys - 1) {
this.keyManager.nextKey();
}
} else {
throw err;
}
}
}
const msg = `All ${totalKeys} configured API keys for provider '${this.providerName}' failed authentication or quota limits. Last error: ${lastError?.message}`;
throw new ProviderError(msg, { originalError: lastError ?? undefined });
}
private async doSearch(
apiKey: string,
query: string,
options: SearchOptions
): Promise<ImageSearchResult[]> {
const rawNum = options.numResults ?? 10;
// Clamp requested result count per Pexels API limit (max 80)
const perPage = Math.min(Math.max(1, rawNum), 80);
const page = options.page ? Math.max(1, Number(options.page)) : 1;
const { apiKey: _k, numResults: _n, provider: _p, page: _pg, ...extraKwargs } = options;
const params: Record<string, string | number | boolean> = {
query,
per_page: perPage,
page,
...(extraKwargs as Record<string, string | number | boolean>),
};
// Pexels API requires raw API key in Authorization header without Bearer prefix
const headers: Record<string, string> = {
Authorization: apiKey.trim(),
accept: "application/json",
};
let response;
try {
response = await this._httpClient.get(PEXELS_SEARCH_URL, {
params,
headers,
});
} catch (exc: unknown) {
const err = exc instanceof Error ? exc : new Error(String(exc));
throw new ProviderError(`Pexels image search HTTP request failed: ${err.message}`, {
originalError: err,
});
}
const data = this.parseJsonResponse(response);
const rawResults = data.photos;
if (!rawResults || !Array.isArray(rawResults)) {
return [];
}
return rawResults.map((item) =>
this.normalizeResult((item as Record<string, unknown>) || {})
);
}
private parseJsonResponse(response: { statusCode: number; data?: unknown; text?: string }): Record<string, unknown> {
if (ROTATION_STATUS_CODES.includes(response.statusCode)) {
throw new KeyAuthOrQuotaError(
`Pexels image search failed with status code ${response.statusCode}: ${response.text || ""}`,
response.statusCode
);
}
if (response.statusCode !== 200) {
throw new ProviderError(
`Pexels image search failed with status code ${response.statusCode}: ${response.text || ""}`
);
}
if (!response.data || typeof response.data !== "object") {
throw new ProviderError("Invalid response payload from Pexels image search API: expected JSON object");
}
const data = response.data as Record<string, unknown>;
if (data.error) {
const errMsg = String(data.error);
const errLower = errMsg.toLowerCase();
if (
errLower.includes("unauthorized") ||
errLower.includes("invalid api key") ||
errLower.includes("rate limit") ||
errLower.includes("quota")
) {
throw new KeyAuthOrQuotaError(
`Pexels API error: ${errMsg}`,
errLower.includes("unauthorized") ? 401 : 429
);
}
throw new ProviderError(`Pexels API error: ${errMsg}`);
}
return data;
}
private normalizeResult(rawItem: Record<string, unknown>): ImageSearchResult {
const srcObj = (rawItem.src as Record<string, unknown>) || {};
const photographer = rawItem.photographer ? String(rawItem.photographer).trim() : "";
const title = rawItem.alt
? String(rawItem.alt).trim()
: photographer
? `Photo by ${photographer}`
: "Pexels Photo";
const imageUrl = String(
srcObj.large2x || srcObj.large || srcObj.original || srcObj.medium || ""
);
const sourceUrl = String(rawItem.url || "");
const sourceDomain = "pexels.com";
let width: number | undefined = undefined;
if (typeof rawItem.width === "number") width = rawItem.width;
let height: number | undefined = undefined;
if (typeof rawItem.height === "number") height = rawItem.height;
const thumbnailUrl =
typeof srcObj.tiny === "string"
? srcObj.tiny
: typeof srcObj.small === "string"
? srcObj.small
: typeof srcObj.medium === "string"
? srcObj.medium
: undefined;
return {
title,
imageUrl,
sourceUrl,
sourceDomain,
width,
height,
thumbnailUrl,
raw: rawItem,
};
}
}
import { HTTPClient } from "../../http/HTTPClient";
import { KeyAuthOrQuotaError, NoAPIKeysError, ProviderError } from "../../models/errors";
import { SearchOptions } from "../../types/search";
import { KeyManager } from "../../utils/KeyManager";
import { ImageSearchResult } from "../models";
import { BaseImageProvider } from "./BaseImageProvider";
export const PEXELS_SEARCH_URL = "https://api.pexels.com/v1/search";
const ROTATION_STATUS_CODES = [401, 403, 429];
export interface PexelsImageProviderOptions {
keys?: string[];
keyManager?: KeyManager;
httpClient?: HTTPClient;
httpTimeoutSeconds?: number;
cooldownHours?: number;
}
/**
* Pexels image search provider implementation.
* Queries Pexels v1 Search API endpoint and normalizes photo objects into ImageSearchResult format.
* Requires PEXELS_KEYS configuration and supports multi-key rotation via KeyManager.
*/
export class PexelsImageProvider extends BaseImageProvider {
public static readonly PROVIDER_NAME = "pexels";
private keyManager: KeyManager;
constructor(options: PexelsImageProviderOptions = {}) {
super({
providerName: PexelsImageProvider.PROVIDER_NAME,
httpClient: options.httpClient,
httpTimeoutSeconds: options.httpTimeoutSeconds,
});
this.keyManager =
options.keyManager ??
new KeyManager({
providerName: PexelsImageProvider.PROVIDER_NAME,
keys: options.keys,
defaultCooldownHours: options.cooldownHours ?? 24.0,
});
}
public override setKeys(keys: string[]): void {
this.keyManager.setKeys(keys);
}
/**
* Execute image search using Pexels Search API endpoint with automatic key rotation.
*
* @param query Search query string.
* @param options Search options (numResults, page, extra kwargs).
* @returns List of normalized ImageSearchResult objects.
* @throws NoAPIKeysError if no API keys are configured for Pexels.
* @throws ProviderError if all API keys fail authentication/quota or request fails.
*/
public async searchImages(
query: string,
options: SearchOptions = {}
): Promise<ImageSearchResult[]> {
if (!query || !query.trim()) {
return [];
}
const cleanQuery = query.trim();
const totalKeys = this.keyManager.keyCount();
if (totalKeys === 0) {
throw new NoAPIKeysError(`No API keys configured for provider '${this.providerName}'`);
}
let lastError: Error | null = null;
for (let attempt = 0; attempt < totalKeys; attempt++) {
const activeKey = this.keyManager.currentKey();
try {
const results = await this.doSearch(activeKey, cleanQuery, options);
this.keyManager.markKeySuccess(activeKey);
return results;
} catch (err: unknown) {
if (err instanceof KeyAuthOrQuotaError) {
lastError = err as Error;
this.keyManager.markKeyExhausted(activeKey, (err as KeyAuthOrQuotaError).retryAfterSeconds);
if (attempt < totalKeys - 1) {
this.keyManager.nextKey();
}
} else {
throw err;
}
}
}
const msg = `All ${totalKeys} configured API keys for provider '${this.providerName}' failed authentication or quota limits. Last error: ${lastError?.message}`;
throw new ProviderError(msg, { originalError: lastError ?? undefined });
}
private async doSearch(
apiKey: string,
query: string,
options: SearchOptions
): Promise<ImageSearchResult[]> {
const rawNum = options.numResults ?? 10;
// Clamp requested result count per Pexels API limit (max 80)
const perPage = Math.min(Math.max(1, rawNum), 80);
const page = options.page ? Math.max(1, Number(options.page)) : 1;
const { apiKey: _k, numResults: _n, provider: _p, page: _pg, ...extraKwargs } = options;
const params: Record<string, string | number | boolean> = {
query,
per_page: perPage,
page,
...(extraKwargs as Record<string, string | number | boolean>),
};
// Pexels API requires raw API key in Authorization header without Bearer prefix
const headers: Record<string, string> = {
Authorization: apiKey.trim(),
accept: "application/json",
};
let response;
try {
response = await this._httpClient.get(PEXELS_SEARCH_URL, {
params,
headers,
});
} catch (exc: unknown) {
const err = exc instanceof Error ? exc : new Error(String(exc));
throw new ProviderError(`Pexels image search HTTP request failed: ${err.message}`, {
originalError: err,
});
}
const data = this.parseJsonResponse(response);
const rawResults = data.photos;
if (!rawResults || !Array.isArray(rawResults)) {
return [];
}
return rawResults.map((item) =>
this.normalizeResult((item as Record<string, unknown>) || {})
);
}
private parseJsonResponse(response: { statusCode: number; data?: unknown; text?: string }): Record<string, unknown> {
if (ROTATION_STATUS_CODES.includes(response.statusCode)) {
throw new KeyAuthOrQuotaError(
`Pexels image search failed with status code ${response.statusCode}: ${response.text || ""}`,
response.statusCode
);
}
if (response.statusCode !== 200) {
throw new ProviderError(
`Pexels image search failed with status code ${response.statusCode}: ${response.text || ""}`
);
}
if (!response.data || typeof response.data !== "object") {
throw new ProviderError("Invalid response payload from Pexels image search API: expected JSON object");
}
const data = response.data as Record<string, unknown>;
if (data.error) {
const errMsg = String(data.error);
const errLower = errMsg.toLowerCase();
if (
errLower.includes("unauthorized") ||
errLower.includes("invalid api key") ||
errLower.includes("rate limit") ||
errLower.includes("quota")
) {
throw new KeyAuthOrQuotaError(
`Pexels API error: ${errMsg}`,
errLower.includes("unauthorized") ? 401 : 429
);
}
throw new ProviderError(`Pexels API error: ${errMsg}`);
}
return data;
}
private normalizeResult(rawItem: Record<string, unknown>): ImageSearchResult {
const srcObj = (rawItem.src as Record<string, unknown>) || {};
const photographer = rawItem.photographer ? String(rawItem.photographer).trim() : "";
const title = rawItem.alt
? String(rawItem.alt).trim()
: photographer
? `Photo by ${photographer}`
: "Pexels Photo";
const imageUrl = String(
srcObj.large2x || srcObj.large || srcObj.original || srcObj.medium || ""
);
const sourceUrl = String(rawItem.url || "");
const sourceDomain = "pexels.com";
let width: number | undefined = undefined;
if (typeof rawItem.width === "number") width = rawItem.width;
let height: number | undefined = undefined;
if (typeof rawItem.height === "number") height = rawItem.height;
const thumbnailUrl =
typeof srcObj.tiny === "string"
? srcObj.tiny
: typeof srcObj.small === "string"
? srcObj.small
: typeof srcObj.medium === "string"
? srcObj.medium
: undefined;
return {
title,
imageUrl,
sourceUrl,
sourceDomain,
width,
height,
thumbnailUrl,
raw: rawItem,
};
}
}