Files
mabibli/MaBibli.Api/Services/Isbn/BnfClient.cs
T

391 lines
17 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>
/// Interroge le SRU <b>par auteur</b> pour bâtir une bibliographie.
/// </summary>
/// <remarks>
/// Renvoie un résultat vide et un <c>Avertissement</c> si la BnF est injoignable : comme
/// pour le lookup ISBN, une source indisponible ne doit pas faire échouer l'écran.
/// </remarks>
Task<(ResultatBibliographie Resultat, string? Avertissement)> RechercherParAuteurAsync(
string auteur, CancellationToken ct = default);
/// <summary>
/// Interroge le SRU <b>par ISSN</b> pour nommer un périodique.
/// </summary>
/// <remarks>
/// Sert uniquement à dire à l'utilisateur ce qu'il a scanné quand le code-barres porte le
/// préfixe <c>977</c> : rien n'est catalogué à partir de là. Une BnF injoignable renvoie
/// <c>null</c> et un avertissement — l'écran dira alors « un magazine, titre inconnu »
/// plutôt que de laisser croire à un échec de recherche de livre.
/// </remarks>
Task<(PeriodiqueDetecte? Periodique, string? Avertissement)> RechercherPeriodiqueAsync(
string issn, CancellationToken ct = default);
/// <summary>
/// Interroge le SRU <b>par titre</b>, éventuellement restreint à un auteur.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
Task<(IReadOnlyList<CandidatLivre> Candidats, string? Avertissement)> RechercherParTitreAsync(
string titre, string? auteur, CancellationToken ct = default);
}
/// <summary>
/// Extension de la recherche par auteur utilisée uniquement pour les nouveautés.
/// </summary>
public interface IBnfClientNouveautes
{
Task<(ResultatBibliographie Resultat, string? Avertissement)>
RechercherParAuteurNouveautesAsync(string auteur, 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, IBnfClientNouveautes
{
/// <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}.");
}
}
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}.");
}
}
/// <summary>Notices rendues par une recherche par titre : de quoi choisir sans faire défiler.</summary>
public const int NoticesRecherche = 20;
public async Task<(IReadOnlyList<CandidatLivre> 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} ».");
}
}
/// <summary>
/// Neutralise les guillemets d'un terme de recherche, qui délimitent la valeur en CQL.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private static string Echapper(string terme) => terme.Trim().Replace("\"", " ");
/// <summary>
/// Notices lues par page, et nombre de pages : au plus <b>200 notices</b>.
/// </summary>
/// <remarks>
/// 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 <b>en parallèle</b>. 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.
/// <para>
/// L'interface <b>doit</b> signaler que la liste est un extrait quand la BnF en annonce plus
/// (<c>BibliographieDto.Tronquee</c>) : une bibliographie partielle présentée comme complète
/// ferait croire à tort qu'un livre n'existe pas.
/// </para>
/// </remarks>
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<ResultatBibliographie> { 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);
}
}
/// <summary>
/// Motif <b>lisible</b> d'une bibliographie que la BnF n'a pas pu fournir.
/// </summary>
/// <remarks>
/// ⚠️ Public parce que ce texte <b>atteint l'utilisateur</b> : 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.
/// <para>
/// 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.
/// </para>
/// </remarks>
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.",
};
/// <summary>Résultat d'une interrogation qui n'a pas abouti : aucune notice, et son motif.</summary>
private static (ResultatBibliographie, string?) Echec(EtatSourceBibliographie etat, string auteur) =>
(new ResultatBibliographie { Etat = etat }, MotifDeSourceMuette(etat, auteur));
private async Task<ResultatBibliographie?> 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);
}
/// <summary>Recolle les pages en un seul résultat, en additionnant les compteurs.</summary>
private static ResultatBibliographie Fusionner(IReadOnlyList<ResultatBibliographie> 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),
};
}