using MaBibli.Shared.Dtos; namespace MaBibli.Api.Services.Isbn; public interface IBnfClient { /// /// 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 ou si elle est injoignable ; /// dans ce second cas explique pourquoi. /// Task<(IReadOnlyList Candidats, string? Avertissement)> RechercherAsync( string isbn, string? urlCouverture, CancellationToken ct = default); /// /// Interroge le SRU par auteur pour bâtir une bibliographie. /// /// /// Renvoie un résultat vide et un Avertissement si la BnF est injoignable : comme /// pour le lookup ISBN, une source indisponible ne doit pas faire échouer l'écran. /// Task<(ResultatBibliographie Resultat, string? Avertissement)> RechercherParAuteurAsync( string auteur, CancellationToken ct = default); } /// /// Client de l'API SRU du catalogue général de la BnF (gratuite, sans clé). /// public sealed class BnfClient(HttpClient http, ILogger logger) : IBnfClient { /// Nom du client typé enregistré dans IHttpClientFactory. public const string NomHttpClient = "bnf"; public const string UrlBase = "https://catalogue.bnf.fr/"; public async Task<(IReadOnlyList 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}."); } } /// /// Notices lues par page, et nombre de pages : au plus 200 notices. /// /// /// Ce plafond est un arbitrage, pas une limite de l'API. Mesuré le 2026-08-18 : une requête /// coûte ~0,85 s, et les deux pages sont donc lancées en parallèle. Aller au-delà /// serait long pour un gain nul dans la plupart des cas — Bernard Werber tient en 203 /// notices, Amélie Nothomb en 277 — et resterait de toute façon insuffisant pour un classique /// très réédité : Émile Zola en compte 2692. Comme le SRU classe par pertinence, les deux /// premières pages contiennent l'essentiel de l'œuvre ; le reste est de la réédition. /// /// L'interface doit signaler que la liste est un extrait quand la BnF en annonce plus /// (BibliographieDto.Tronquee) : une bibliographie partielle présentée comme complète /// ferait croire à tort qu'un livre n'existe pas. /// /// public const int NoticesParPage = 100; public const int PagesMaximum = 2; public async Task<(ResultatBibliographie Resultat, string? Avertissement)> RechercherParAuteurAsync( string auteur, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(auteur)) { return (new ResultatBibliographie(), null); } try { // Les pages sont lancées ensemble : à ~0,85 s l'unité, les enchaîner doublerait // l'attente de l'écran pour rien. var pages = await Task.WhenAll( Enumerable.Range(0, PagesMaximum) .Select(i => LirePageAsync(auteur, (i * NoticesParPage) + 1, ct))); var lues = pages.Where(p => p is not null).Select(p => p!).ToList(); if (lues.Count == 0) { return (new ResultatBibliographie(), $"La BnF n'a pas répondu pour « {auteur} »."); } return (Fusionner(lues), null); } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) { logger.LogWarning(ex, "BnF injoignable pour l'auteur {Auteur}", auteur); return (new ResultatBibliographie(), $"BnF injoignable ({ex.GetType().Name}) pour « {auteur} »."); } catch (System.Xml.XmlException ex) { logger.LogWarning(ex, "Réponse BnF illisible pour l'auteur {Auteur}", auteur); return (new ResultatBibliographie(), $"Réponse BnF illisible pour « {auteur} »."); } } private async Task LirePageAsync( string auteur, int premiereNotice, CancellationToken ct) { // bib.author : index auteur du catalogue général, vérifié le 2026-08-18. // « all » exige que tous les mots soient présents — mais sur l'ENSEMBLE des auteurs de la // notice, d'où le post-filtre de BnfBibliographieParser. var url = "api/SRU" + "?version=1.2" + "&operation=searchRetrieve" + $"&query={Uri.EscapeDataString($"bib.author all \"{auteur}\"")}" + "&recordSchema=dublincore" + $"&maximumRecords={NoticesParPage}" + $"&startRecord={premiereNotice}"; using var reponse = await http.GetAsync(url, ct); if (!reponse.IsSuccessStatusCode) { logger.LogWarning( "BnF a répondu {Code} pour l'auteur {Auteur} (notice {Debut})", (int)reponse.StatusCode, auteur, premiereNotice); return null; } var xml = await reponse.Content.ReadAsStringAsync(ct); return BnfBibliographieParser.Parser(xml, auteur); } /// Recolle les pages en un seul résultat, en additionnant les compteurs. private static ResultatBibliographie Fusionner(IReadOnlyList pages) => new() { Notices = pages.SelectMany(p => p.Notices).ToList(), // Le total annoncé est le même sur chaque page : on le prend, on ne l'additionne pas. NombreAnnonce = pages.Max(p => p.NombreAnnonce), NombreLu = pages.Sum(p => p.NombreLu), EcarteesTypeNonLivre = pages.Sum(p => p.EcarteesTypeNonLivre), EcarteesAutreAuteur = pages.Sum(p => p.EcarteesAutreAuteur), }; }