La liste d'envies vit dans une table séparée (LivreSouhaite) plutôt que dans un statut de plus sur Livre : un livre souhaité n'est pas possédé, et le loger dans Livres l'aurait fait entrer dans le catalogue, les compteurs et les prêts, au prix d'un « et qui n'est pas souhaité » à répéter dans chaque lecture. La portée est personnelle, comme le statut de lecture — mais ici Utilisateur est une vraie frontière : toute lecture filtre dessus. Bibliographie : SRU BnF interrogé par bib.author, vérifié le 2026-08-18. Deux filtres mesurés sur des réponses réelles sont indispensables — le type de document (l'index mêle livres audio, jeux et spectacles) et surtout l'auteur réel de la notice, « all » rapprochant les mots sur l'ensemble des auteurs : « Émile Zola » remonte sinon toute l'œuvre de sa fille Denise Le Blond-Zola. Les rééditions sont regroupées par clé d'œuvre (183 notices Werber -> 51 œuvres). Le rapprochement avec l'étagère se fait par titre, pas par ISBN, qui désigne une édition et non une œuvre ; ses limites sont dites à l'écran. Export en deux formats, tous deux du texte sans dépendance : .txt groupé par auteur pour la librairie, .csv à séparateur point-virgule et BOM pour le tableur. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
169 lines
7.5 KiB
C#
169 lines
7.5 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>
|
|
/// 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}.");
|
|
}
|
|
}
|
|
|
|
/// <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 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<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),
|
|
};
|
|
}
|