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); /// /// Interroge le SRU par ISSN pour nommer un périodique. /// /// /// Sert uniquement à dire à l'utilisateur ce qu'il a scanné quand le code-barres porte le /// préfixe 977 : rien n'est catalogué à partir de là. Une BnF injoignable renvoie /// null et un avertissement — l'écran dira alors « un magazine, titre inconnu » /// plutôt que de laisser croire à un échec de recherche de livre. /// Task<(PeriodiqueDetecte? Periodique, string? Avertissement)> RechercherPeriodiqueAsync( string issn, CancellationToken ct = default); /// /// Interroge le SRU par titre, éventuellement restreint à un auteur. /// /// /// Sert l'ajout à la liste d'envies quand on n'a pas le livre en main : on souhaite une /// œuvre dont on connaît le titre, rarement l'ISBN. /// Task<(IReadOnlyList Candidats, string? Avertissement)> RechercherParTitreAsync( string titre, string? auteur, CancellationToken ct = default); } /// /// Extension de la recherche par auteur utilisée uniquement pour les nouveautés. /// public interface IBnfClientNouveautes { Task<(ResultatBibliographie Resultat, string? Avertissement)> RechercherParAuteurNouveautesAsync(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, IBnfClientNouveautes { /// 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}."); } } public async Task<(PeriodiqueDetecte? Periodique, string? Avertissement)> RechercherPeriodiqueAsync( string issn, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(issn)) { return (null, null); } // bib.issn, vérifié le 2026-08-18 : l'ISSN doit porter son tiret (« 2466-6718 »), // forme canonique sous laquelle la BnF l'indexe. var url = "api/SRU" + "?version=1.2" + "&operation=searchRetrieve" + $"&query={Uri.EscapeDataString($"bib.issn all \"{issn}\"")}" + "&recordSchema=dublincore" + "&maximumRecords=1"; try { using var reponse = await http.GetAsync(url, ct); if (!reponse.IsSuccessStatusCode) { logger.LogWarning("BnF a répondu {Code} pour l'ISSN {Issn}", (int)reponse.StatusCode, issn); return (null, $"La BnF a répondu {(int)reponse.StatusCode} pour l'ISSN {issn}."); } var xml = await reponse.Content.ReadAsStringAsync(ct); return (BnfSruParser.ParserPeriodique(xml, issn), null); } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) { logger.LogWarning(ex, "BnF injoignable pour l'ISSN {Issn}", issn); return (null, $"BnF injoignable ({ex.GetType().Name}) pour l'ISSN {issn}."); } catch (System.Xml.XmlException ex) { logger.LogWarning(ex, "Réponse BnF illisible pour l'ISSN {Issn}", issn); return (null, $"Réponse BnF illisible pour l'ISSN {issn}."); } } /// Notices rendues par une recherche par titre : de quoi choisir sans faire défiler. public const int NoticesRecherche = 20; public async Task<(IReadOnlyList Candidats, string? Avertissement)> RechercherParTitreAsync( string titre, string? auteur, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(titre)) { return ([], null); } // Les deux index se combinent en CQL par « and ». L'auteur est facultatif : sans lui la // recherche porte sur le seul titre, ce qui suffit pour un titre un peu distinctif. var cql = $"bib.title all \"{Echapper(titre)}\""; if (!string.IsNullOrWhiteSpace(auteur)) { cql += $" and bib.author all \"{Echapper(auteur)}\""; } var url = "api/SRU" + "?version=1.2" + "&operation=searchRetrieve" + $"&query={Uri.EscapeDataString(cql)}" + "&recordSchema=dublincore" + $"&maximumRecords={NoticesRecherche}"; try { using var reponse = await http.GetAsync(url, ct); if (!reponse.IsSuccessStatusCode) { logger.LogWarning("BnF a répondu {Code} pour le titre {Titre}", (int)reponse.StatusCode, titre); return ([], $"La BnF a répondu {(int)reponse.StatusCode} pour « {titre} »."); } var xml = await reponse.Content.ReadAsStringAsync(ct); return (BnfBibliographieParser.ParserRecherche(xml), null); } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) { logger.LogWarning(ex, "BnF injoignable pour le titre {Titre}", titre); return ([], $"BnF injoignable ({ex.GetType().Name}) pour « {titre} »."); } catch (System.Xml.XmlException ex) { logger.LogWarning(ex, "Réponse BnF illisible pour le titre {Titre}", titre); return ([], $"Réponse BnF illisible pour « {titre} »."); } } /// /// Neutralise les guillemets d'un terme de recherche, qui délimitent la valeur en CQL. /// /// /// Un titre contenant un guillemet fermerait la chaîne et rendrait la requête invalide — /// « L'homme qui plantait des arbres » n'en a pas, mais rien ne l'interdit à un utilisateur. /// private static string Echapper(string terme) => terme.Trim().Replace("\"", " "); /// /// 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 const int PagesMaximumNouveautes = 10; public static int NombrePagesNouveautes(int nombreAnnonce) => Math.Min( PagesMaximumNouveautes, Math.Max(1, (nombreAnnonce + NoticesParPage - 1) / NoticesParPage)); 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 Echec(EtatSourceBibliographie.Injoignable, auteur); } return (Fusionner(lues), null); } // ⚠️ Le délai dépassé se distingue de l'injoignable, et ce n'est pas du détail : c'est // le cas remonté en usage, et le seul qui vaille la peine d'être retenté tout de suite. // HttpClient l'annonce par une TaskCanceledException dont l'exception interne est un // TimeoutException — sans ce test, il se confondrait avec une annulation de l'appelant. catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) { logger.LogWarning(ex, "Délai dépassé par la BnF pour l'auteur {Auteur}", auteur); return Echec(EtatSourceBibliographie.DelaiDepasse, auteur); } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) { logger.LogWarning(ex, "BnF injoignable pour l'auteur {Auteur}", auteur); return Echec(EtatSourceBibliographie.Injoignable, auteur); } catch (System.Xml.XmlException ex) { logger.LogWarning(ex, "Réponse BnF illisible pour l'auteur {Auteur}", auteur); return Echec(EtatSourceBibliographie.ReponseIllisible, auteur); } } public async Task<(ResultatBibliographie Resultat, string? Avertissement)> RechercherParAuteurNouveautesAsync(string auteur, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(auteur)) { return (new ResultatBibliographie(), null); } try { var premiere = await LirePageAsync(auteur, 1, ct); if (premiere is null) { return Echec(EtatSourceBibliographie.Injoignable, auteur); } var nombrePages = NombrePagesNouveautes(premiere.NombreAnnonce); var pages = new List { premiere }; if (nombrePages > 1) { var suivantes = await Task.WhenAll( Enumerable.Range(1, nombrePages - 1) .Select(i => LirePageAsync(auteur, (i * NoticesParPage) + 1, ct))); pages.AddRange(suivantes.Where(p => p is not null).Select(p => p!)); } return (Fusionner(pages), null); } catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) { logger.LogWarning(ex, "Délai dépassé par la BnF pour les nouveautés de {Auteur}", auteur); return Echec(EtatSourceBibliographie.DelaiDepasse, auteur); } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) { logger.LogWarning(ex, "BnF injoignable pour les nouveautés de {Auteur}", auteur); return Echec(EtatSourceBibliographie.Injoignable, auteur); } catch (System.Xml.XmlException ex) { logger.LogWarning(ex, "Réponse BnF illisible pour les nouveautés de {Auteur}", auteur); return Echec(EtatSourceBibliographie.ReponseIllisible, auteur); } } /// /// Motif lisible d'une bibliographie que la BnF n'a pas pu fournir. /// /// /// ⚠️ Public parce que ce texte atteint l'utilisateur : c'est un contrat d'interface, /// pas un détail d'implémentation, et il est verrouillé par des tests. Il ne contient aucun /// nom de classe .NET — « BnF injoignable (TaskCanceledException) » est ce qui s'affichait /// avant, et « TaskCanceledException » ne veut rien dire pour qui range ses livres. /// /// Les trois motifs se distinguent parce qu'ils n'appellent pas la même chose : un délai /// dépassé se retente tout de suite, une réponse illisible non. /// /// public static string MotifDeSourceMuette(EtatSourceBibliographie etat, string auteur) => etat switch { EtatSourceBibliographie.DelaiDepasse => $"La BnF n'a pas répondu à temps pour « {auteur} ». Son catalogue est parfois lent ;" + " réessayer suffit le plus souvent.", EtatSourceBibliographie.ReponseIllisible => $"La BnF a répondu quelque chose d'inexploitable pour « {auteur} ».", _ => $"La BnF n'a pas pu être jointe pour « {auteur} ». Vérifiez votre connexion," + " ou réessayez plus tard.", }; /// Résultat d'une interrogation qui n'a pas abouti : aucune notice, et son motif. private static (ResultatBibliographie, string?) Echec(EtatSourceBibliographie etat, string auteur) => (new ResultatBibliographie { Etat = etat }, MotifDeSourceMuette(etat, 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), }; }