Files
mabibli/MaBibli.Api/Services/Isbn/OpenLibraryClient.cs
T
mathieuandClaude Opus 5 f8f30dbcf7 Phase 2 : service de lookup ISBN en cascade BnF puis OpenLibrary
Recuperation des metadonnees d'un livre a partir de son ISBN, cote serveur
uniquement (aucune interface, aucune persistance).

Cascade : BnF en ISBN-13, puis BnF en ISBN-10 converti, puis OpenLibrary.
La conversion 13 -> 10 est indispensable et non optionnelle : la BnF indexe
l'ISBN tel qu'imprime, les ouvrages d'avant 2007 ne portent qu'un ISBN-10 et
sont introuvables par l'EAN-13 que lit le scanner.

Toutes les notices trouvees sont remontees (jusqu'a 5) : un meme ISBN peut
correspondre a plusieurs reeditions, le choix revient a l'utilisateur.

Le double appel /isbn puis /authors d'OpenLibrary est bien fait, avec repli
sur l'oeuvre quand l'edition ne porte aucun auteur : c'est le bug de BookLogr
(titre rempli, auteur vide) qu'il ne faut pas reproduire.

La couverture vient toujours d'OpenLibrary, avec ?default=false pour obtenir
un 404 plutot qu'une image placeholder.

87 tests xUnit, sur fixtures enregistrees : aucune dependance au reseau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:22:47 +02:00

109 lines
4.1 KiB
C#

using System.Net;
using System.Text.Json;
using MaBibli.Shared.Dtos;
namespace MaBibli.Api.Services.Isbn;
public interface IOpenLibraryClient
{
/// <summary>
/// Cherche l'édition correspondant à un ISBN chez OpenLibrary, auteurs résolus.
/// Renvoie une liste vide si l'ISBN est inconnu ou la source injoignable.
/// </summary>
Task<(IReadOnlyList<CandidatLivre> Candidats, string? Avertissement)> RechercherAsync(
string isbn, string? urlCouverture, CancellationToken ct = default);
}
/// <summary>
/// Client OpenLibrary — second rideau de la cascade, pour les livres étrangers et tout ce que
/// la BnF ne connaît pas.
/// </summary>
/// <remarks>
/// <b>Bug à ne pas reproduire</b> (observé chez BookLogr) : <c>/isbn/{isbn}.json</c> ne donne pas
/// le nom de l'auteur mais une référence <c>/authors/OL…A</c>. Sans second appel, l'auteur reste
/// vide. Ce client fait ce second appel, et bascule sur l'œuvre rattachée quand l'édition ne
/// porte aucun auteur (cas mesuré : Introduction to Algorithms).
/// </remarks>
public sealed class OpenLibraryClient(HttpClient http, ILogger<OpenLibraryClient> logger) : IOpenLibraryClient
{
public const string NomHttpClient = "openlibrary";
public const string UrlBase = "https://openlibrary.org/";
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
public async Task<(IReadOnlyList<CandidatLivre> Candidats, string? Avertissement)> RechercherAsync(
string isbn, string? urlCouverture, CancellationToken ct = default)
{
try
{
// /isbn/{isbn}.json répond en 302 vers /books/OL…M.json quand le livre existe, 404 sinon.
// Le HttpClient suit la redirection par défaut.
var edition = await LireAsync<OpenLibraryEdition>($"isbn/{isbn}.json", ct);
if (edition is null)
{
return ([], null);
}
var noms = await ResoudreAuteursAsync(edition, ct);
var candidat = OpenLibraryMapper.VersCandidat(edition, noms, isbn, urlCouverture);
return (candidat is null ? [] : [candidat], null);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
{
logger.LogWarning(ex, "OpenLibrary injoignable ou illisible pour l'ISBN {Isbn}", isbn);
return ([], $"OpenLibrary injoignable ({ex.GetType().Name}) pour {isbn}.");
}
}
private async Task<IReadOnlyList<string>> ResoudreAuteursAsync(OpenLibraryEdition edition, CancellationToken ct)
{
var cles = OpenLibraryMapper.ClesAuteurs(edition);
if (cles.Count == 0)
{
// Repli : certaines éditions n'ont pas de champ `authors`, seule l'œuvre en porte.
var cleOeuvre = OpenLibraryMapper.CleOeuvre(edition);
if (cleOeuvre is not null)
{
var oeuvre = await LireAsync<OpenLibraryWork>(cleOeuvre.TrimStart('/') + ".json", ct);
if (oeuvre is not null)
{
cles = OpenLibraryMapper.ClesAuteurs(oeuvre);
}
}
}
var noms = new List<string>();
foreach (var cle in cles)
{
var auteur = await LireAsync<OpenLibraryAuthor>(cle.TrimStart('/') + ".json", ct);
var nom = auteur is null ? null : OpenLibraryMapper.Nom(auteur);
if (nom is not null)
{
noms.Add(nom);
}
}
return noms;
}
private async Task<T?> LireAsync<T>(string chemin, CancellationToken ct) where T : class
{
using var reponse = await http.GetAsync(chemin, ct);
if (reponse.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
if (!reponse.IsSuccessStatusCode)
{
logger.LogWarning("OpenLibrary a répondu {Code} pour {Chemin}", (int)reponse.StatusCode, chemin);
return null;
}
await using var flux = await reponse.Content.ReadAsStreamAsync(ct);
return await JsonSerializer.DeserializeAsync<T>(flux, Json, ct);
}
}