Files
mabibli/MaBibli.Api/Services/Isbn/BnfClient.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

63 lines
2.6 KiB
C#

using MaBibli.Shared.Dtos;
namespace MaBibli.Api.Services.Isbn;
public interface IBnfClient
{
/// <summary>
/// Interroge le SRU de la BnF pour une forme d'ISBN donnée.
/// Renvoie une liste vide si la BnF ne connaît pas l'ISBN <b>ou</b> si elle est injoignable ;
/// dans ce second cas <paramref name="avertissement"/> explique pourquoi.
/// </summary>
Task<(IReadOnlyList<CandidatLivre> Candidats, string? Avertissement)> RechercherAsync(
string isbn, string? urlCouverture, CancellationToken ct = default);
}
/// <summary>
/// Client de l'API SRU du catalogue général de la BnF (gratuite, sans clé).
/// </summary>
public sealed class BnfClient(HttpClient http, ILogger<BnfClient> logger) : IBnfClient
{
/// <summary>Nom du client typé enregistré dans <c>IHttpClientFactory</c>.</summary>
public const string NomHttpClient = "bnf";
public const string UrlBase = "https://catalogue.bnf.fr/";
public async Task<(IReadOnlyList<CandidatLivre> Candidats, string? Avertissement)> RechercherAsync(
string isbn, string? urlCouverture, CancellationToken ct = default)
{
// recordSchema=dublincore et non MARC : dc:title / dc:creator / dc:publisher se mappent
// directement, là où l'UNIMARC demanderait un décodage complet.
var url = "api/SRU"
+ "?version=1.2"
+ "&operation=searchRetrieve"
+ $"&query={Uri.EscapeDataString($"bib.isbn all \"{isbn}\"")}"
+ "&recordSchema=dublincore"
+ "&maximumRecords=5";
try
{
using var reponse = await http.GetAsync(url, ct);
if (!reponse.IsSuccessStatusCode)
{
logger.LogWarning("BnF a répondu {Code} pour l'ISBN {Isbn}", (int)reponse.StatusCode, isbn);
return ([], $"La BnF a répondu {(int)reponse.StatusCode} pour {isbn}.");
}
var xml = await reponse.Content.ReadAsStringAsync(ct);
return (BnfSruParser.Parser(xml, isbn, urlCouverture), null);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
// Une source indisponible ne doit pas faire échouer la cascade : on bascule sur la suivante.
logger.LogWarning(ex, "BnF injoignable pour l'ISBN {Isbn}", isbn);
return ([], $"BnF injoignable ({ex.GetType().Name}) pour {isbn}.");
}
catch (System.Xml.XmlException ex)
{
logger.LogWarning(ex, "Réponse BnF illisible pour l'ISBN {Isbn}", isbn);
return ([], $"Réponse BnF illisible pour {isbn}.");
}
}
}