gutendex integration phase 2
This commit is contained in:
@@ -0,0 +1,260 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\DTOs;
|
||||||
|
|
||||||
|
class GutendexBook
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $id,
|
||||||
|
public string $title,
|
||||||
|
public array $authors,
|
||||||
|
public array $subjects,
|
||||||
|
public array $bookshelves,
|
||||||
|
public array $languages,
|
||||||
|
public ?string $copyright,
|
||||||
|
public string $mediaType,
|
||||||
|
public array $formats,
|
||||||
|
public int $downloadCount,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a GutendexBook DTO from the Gutendex API response.
|
||||||
|
*
|
||||||
|
* @param array $data API response data
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function fromApiResponse(array $data): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
id: $data['id'] ?? 0,
|
||||||
|
title: $data['title'] ?? '',
|
||||||
|
authors: self::parseAuthors($data['authors'] ?? []),
|
||||||
|
subjects: $data['subjects'] ?? [],
|
||||||
|
bookshelves: $data['bookshelves'] ?? [],
|
||||||
|
languages: $data['languages'] ?? [],
|
||||||
|
copyright: $data['copyright'] ?? null,
|
||||||
|
mediaType: $data['media_type'] ?? 'Text',
|
||||||
|
formats: $data['formats'] ?? [],
|
||||||
|
downloadCount: $data['download_count'] ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse authors from API response into a simplified array.
|
||||||
|
*
|
||||||
|
* @param array $authors Raw authors data from API
|
||||||
|
* @return array Formatted authors with name, birth_year, death_year
|
||||||
|
*/
|
||||||
|
protected static function parseAuthors(array $authors): array
|
||||||
|
{
|
||||||
|
return array_map(function ($author) {
|
||||||
|
return [
|
||||||
|
'name' => $author['name'] ?? 'Unknown',
|
||||||
|
'birth_year' => $author['birth_year'] ?? null,
|
||||||
|
'death_year' => $author['death_year'] ?? null,
|
||||||
|
];
|
||||||
|
}, $authors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the primary author name.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getPrimaryAuthorName(): string
|
||||||
|
{
|
||||||
|
if (empty($this->authors)) {
|
||||||
|
return 'Unknown Author';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->authors[0]['name'] ?? 'Unknown Author';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all author names as a string.
|
||||||
|
*
|
||||||
|
* @param string $separator Separator between author names
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getAuthorNamesString(string $separator = ', '): string
|
||||||
|
{
|
||||||
|
$names = array_map(fn($author) => $author['name'], $this->authors);
|
||||||
|
return implode($separator, $names) ?: 'Unknown Author';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the plain text URL if available.
|
||||||
|
*
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
public function getPlainTextUrl(): ?string
|
||||||
|
{
|
||||||
|
// Priority order for text formats
|
||||||
|
$preferredFormats = [
|
||||||
|
'text/plain; charset=utf-8',
|
||||||
|
'text/plain; charset=us-ascii',
|
||||||
|
'text/plain',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($preferredFormats as $format) {
|
||||||
|
if (isset($this->formats[$format])) {
|
||||||
|
return $this->formats[$format];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: look for any text/plain format
|
||||||
|
foreach ($this->formats as $format => $url) {
|
||||||
|
if (str_contains($format, 'text/plain')) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the HTML URL if available.
|
||||||
|
*
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
public function getHtmlUrl(): ?string
|
||||||
|
{
|
||||||
|
return $this->formats['text/html'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the cover image URL.
|
||||||
|
*
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
public function getCoverImageUrl(): ?string
|
||||||
|
{
|
||||||
|
return $this->formats['image/jpeg'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the primary language.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getPrimaryLanguage(): string
|
||||||
|
{
|
||||||
|
return $this->languages[0] ?? 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the most appropriate category from subjects and bookshelves.
|
||||||
|
*
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
public function suggestCategory(): ?string
|
||||||
|
{
|
||||||
|
$categoryMapping = config('gutendex.category_mapping', []);
|
||||||
|
|
||||||
|
// Check bookshelves first (usually more specific)
|
||||||
|
foreach ($this->bookshelves as $bookshelf) {
|
||||||
|
foreach ($categoryMapping as $key => $slug) {
|
||||||
|
if (stripos($bookshelf, $key) !== false) {
|
||||||
|
return $slug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then check subjects
|
||||||
|
foreach ($this->subjects as $subject) {
|
||||||
|
foreach ($categoryMapping as $key => $slug) {
|
||||||
|
if (stripos($subject, $key) !== false) {
|
||||||
|
return $slug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default category based on common patterns
|
||||||
|
$allText = implode(' ', array_merge($this->subjects, $this->bookshelves));
|
||||||
|
$allTextLower = strtolower($allText);
|
||||||
|
|
||||||
|
if (str_contains($allTextLower, 'fiction')) {
|
||||||
|
return 'fiction';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($allTextLower, 'history')) {
|
||||||
|
return 'history';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($allTextLower, 'science')) {
|
||||||
|
return 'science';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($allTextLower, 'philosophy')) {
|
||||||
|
return 'philosophy';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a description from subjects and bookshelves.
|
||||||
|
*
|
||||||
|
* @param int $maxLength Maximum length of description
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function generateDescription(int $maxLength = 200): string
|
||||||
|
{
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
if (!empty($this->subjects)) {
|
||||||
|
$parts[] = 'Subjects: ' . implode(', ', array_slice($this->subjects, 0, 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($this->bookshelves)) {
|
||||||
|
$parts[] = 'Categories: ' . implode(', ', array_slice($this->bookshelves, 0, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
$description = implode('. ', $parts);
|
||||||
|
|
||||||
|
if (strlen($description) > $maxLength) {
|
||||||
|
$description = substr($description, 0, $maxLength - 3) . '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $description ?: 'A book from Project Gutenberg.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this book is in the public domain.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isPublicDomain(): bool
|
||||||
|
{
|
||||||
|
return $this->copyright === null || $this->copyright === false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert the DTO to an array suitable for database insertion.
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function toBookArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'gutenberg_id' => $this->id,
|
||||||
|
'title' => $this->title,
|
||||||
|
'author' => $this->getPrimaryAuthorName(),
|
||||||
|
'cover_image' => $this->getCoverImageUrl(),
|
||||||
|
'description' => $this->generateDescription(),
|
||||||
|
'language' => $this->getPrimaryLanguage(),
|
||||||
|
'full_text_url' => $this->getPlainTextUrl(),
|
||||||
|
'source' => 'gutendex',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all author names for the many-to-many relationship.
|
||||||
|
*
|
||||||
|
* @return array Array of author names
|
||||||
|
*/
|
||||||
|
public function getAuthorNames(): array
|
||||||
|
{
|
||||||
|
return array_map(fn($author) => $author['name'], $this->authors);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,10 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
//
|
// Register GutendexClient as a singleton
|
||||||
|
$this->app->singleton(\App\Services\GutendexClient::class, function ($app) {
|
||||||
|
return new \App\Services\GutendexClient();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\DTOs\GutendexBook;
|
||||||
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
|
use Illuminate\Http\Client\RequestException;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class GutendexClient
|
||||||
|
{
|
||||||
|
protected string $baseUrl;
|
||||||
|
protected int $timeout;
|
||||||
|
protected int $retryMaxAttempts;
|
||||||
|
protected int $retryBackoff;
|
||||||
|
protected bool $cacheEnabled;
|
||||||
|
protected int $cacheTtl;
|
||||||
|
protected int $rateLimit;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->baseUrl = config('gutendex.api_url', 'https://gutendex.com');
|
||||||
|
$this->timeout = config('gutendex.timeout', 30);
|
||||||
|
$this->retryMaxAttempts = config('gutendex.retry.max_attempts', 3);
|
||||||
|
$this->retryBackoff = config('gutendex.retry.backoff_multiplier', 2);
|
||||||
|
$this->cacheEnabled = config('gutendex.cache.enabled', true);
|
||||||
|
$this->cacheTtl = config('gutendex.cache.ttl', 86400);
|
||||||
|
$this->rateLimit = config('gutendex.rate_limit_delay', 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for books with optional query and filters.
|
||||||
|
*
|
||||||
|
* @param string|null $query Search query
|
||||||
|
* @param int $page Page number
|
||||||
|
* @param array $filters Additional filters (language, topic, author, etc.)
|
||||||
|
* @return array API response with results
|
||||||
|
*/
|
||||||
|
public function searchBooks(?string $query = null, int $page = 1, array $filters = []): array
|
||||||
|
{
|
||||||
|
$params = array_merge(['page' => $page], $filters);
|
||||||
|
|
||||||
|
if ($query) {
|
||||||
|
$params['search'] = $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheKey = 'gutendex:search:' . md5(json_encode($params));
|
||||||
|
|
||||||
|
if ($this->cacheEnabled && Cache::has($cacheKey)) {
|
||||||
|
Log::info('Gutendex: Cache hit for search', ['params' => $params]);
|
||||||
|
return Cache::get($cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->makeRequest('GET', '/books', $params);
|
||||||
|
|
||||||
|
if ($this->cacheEnabled && isset($response['results'])) {
|
||||||
|
Cache::put($cacheKey, $response, $this->cacheTtl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific book by its Gutenberg ID.
|
||||||
|
*
|
||||||
|
* @param int $gutenbergId The Gutenberg book ID
|
||||||
|
* @return GutendexBook|null
|
||||||
|
*/
|
||||||
|
public function getBook(int $gutenbergId): ?GutendexBook
|
||||||
|
{
|
||||||
|
$cacheKey = 'gutendex:book:' . $gutenbergId;
|
||||||
|
|
||||||
|
if ($this->cacheEnabled && Cache::has($cacheKey)) {
|
||||||
|
Log::info('Gutendex: Cache hit for book', ['id' => $gutenbergId]);
|
||||||
|
return Cache::get($cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->makeRequest('GET', "/books/{$gutenbergId}");
|
||||||
|
|
||||||
|
if (empty($response)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$book = GutendexBook::fromApiResponse($response);
|
||||||
|
|
||||||
|
if ($this->cacheEnabled) {
|
||||||
|
Cache::put($cacheKey, $book, $this->cacheTtl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $book;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Gutendex: Failed to fetch book', [
|
||||||
|
'id' => $gutenbergId,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get books by language.
|
||||||
|
*
|
||||||
|
* @param string $language Language code (e.g., 'en', 'fr')
|
||||||
|
* @param int $page Page number
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function getBooksByLanguage(string $language = 'en', int $page = 1): array
|
||||||
|
{
|
||||||
|
return $this->searchBooks(null, $page, ['languages' => $language]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get books filtered by copyright status.
|
||||||
|
*
|
||||||
|
* @param string $copyright 'true', 'false', or 'null'
|
||||||
|
* @param int $page Page number
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function getBooksByCopyright(string $copyright = 'false', int $page = 1): array
|
||||||
|
{
|
||||||
|
return $this->searchBooks(null, $page, ['copyright' => $copyright]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch full text content from a given URL.
|
||||||
|
*
|
||||||
|
* @param string $textUrl URL to the full text
|
||||||
|
* @return string|null The text content or null on failure
|
||||||
|
*/
|
||||||
|
public function fetchFullText(string $textUrl): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
Log::info('Gutendex: Fetching full text', ['url' => $textUrl]);
|
||||||
|
|
||||||
|
$response = Http::timeout($this->timeout * 2)
|
||||||
|
->retry($this->retryMaxAttempts, $this->retryBackoff * 1000)
|
||||||
|
->get($textUrl);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
$text = $response->body();
|
||||||
|
Log::info('Gutendex: Successfully fetched full text', [
|
||||||
|
'url' => $textUrl,
|
||||||
|
'size' => strlen($text),
|
||||||
|
]);
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::warning('Gutendex: Failed to fetch full text', [
|
||||||
|
'url' => $textUrl,
|
||||||
|
'status' => $response->status(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (ConnectionException $e) {
|
||||||
|
Log::error('Gutendex: Connection error fetching full text', [
|
||||||
|
'url' => $textUrl,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Gutendex: Error fetching full text', [
|
||||||
|
'url' => $textUrl,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get books with pagination info.
|
||||||
|
*
|
||||||
|
* @param array $filters Filters to apply
|
||||||
|
* @param int $page Page number
|
||||||
|
* @return array Contains 'books' (GutendexBook[]), 'count', 'next', 'previous'
|
||||||
|
*/
|
||||||
|
public function getBooksWithPagination(array $filters = [], int $page = 1): array
|
||||||
|
{
|
||||||
|
$response = $this->searchBooks(null, $page, $filters);
|
||||||
|
|
||||||
|
$books = array_map(
|
||||||
|
fn($bookData) => GutendexBook::fromApiResponse($bookData),
|
||||||
|
$response['results'] ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'books' => $books,
|
||||||
|
'count' => $response['count'] ?? 0,
|
||||||
|
'next' => $response['next'] ?? null,
|
||||||
|
'previous' => $response['previous'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the Gutendex API is accessible.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function checkConnection(): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$response = Http::timeout(10)->get($this->baseUrl . '/books?page=1');
|
||||||
|
return $response->successful();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Gutendex: Connection check failed', ['error' => $e->getMessage()]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make an HTTP request to the Gutendex API with retry logic.
|
||||||
|
*
|
||||||
|
* @param string $method HTTP method
|
||||||
|
* @param string $endpoint API endpoint
|
||||||
|
* @param array $params Query parameters
|
||||||
|
* @return array Response data
|
||||||
|
* @throws RequestException
|
||||||
|
*/
|
||||||
|
protected function makeRequest(string $method, string $endpoint, array $params = []): array
|
||||||
|
{
|
||||||
|
$url = $this->baseUrl . $endpoint;
|
||||||
|
$startTime = microtime(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Http::timeout($this->timeout)
|
||||||
|
->retry(
|
||||||
|
$this->retryMaxAttempts,
|
||||||
|
function ($exception, $request) {
|
||||||
|
// Exponential backoff
|
||||||
|
return $this->retryBackoff * 1000;
|
||||||
|
},
|
||||||
|
function ($exception, $request) {
|
||||||
|
// Retry on connection errors and 429 rate limit
|
||||||
|
if ($exception instanceof ConnectionException) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method_exists($exception, 'response')) {
|
||||||
|
$response = $exception->response;
|
||||||
|
return $response && $response->status() === 429;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
->acceptJson()
|
||||||
|
->$method($url, $params);
|
||||||
|
|
||||||
|
$duration = microtime(true) - $startTime;
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
Log::info('Gutendex: API request successful', [
|
||||||
|
'method' => $method,
|
||||||
|
'endpoint' => $endpoint,
|
||||||
|
'duration' => round($duration, 2) . 's',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add rate limit delay
|
||||||
|
if ($this->rateLimit > 0) {
|
||||||
|
usleep($this->rateLimit * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response->json();
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::warning('Gutendex: API request failed', [
|
||||||
|
'method' => $method,
|
||||||
|
'endpoint' => $endpoint,
|
||||||
|
'status' => $response->status(),
|
||||||
|
'duration' => round($duration, 2) . 's',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [];
|
||||||
|
} catch (ConnectionException $e) {
|
||||||
|
Log::error('Gutendex: Connection error', [
|
||||||
|
'method' => $method,
|
||||||
|
'endpoint' => $endpoint,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
throw $e;
|
||||||
|
} catch (RequestException $e) {
|
||||||
|
$duration = microtime(true) - $startTime;
|
||||||
|
|
||||||
|
Log::error('Gutendex: Request exception', [
|
||||||
|
'method' => $method,
|
||||||
|
'endpoint' => $endpoint,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'duration' => round($duration, 2) . 's',
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all cached Gutendex data.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function clearCache(): void
|
||||||
|
{
|
||||||
|
Cache::flush();
|
||||||
|
Log::info('Gutendex: Cache cleared');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user