Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
391 lines
17 KiB
C#
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),
|
|
};
|
|
}
|