Ajouter la liste d'envies personnelle, son export et la bibliographie par auteur
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>
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
using System.Xml.Linq;
|
||||
using MaBibli.Shared.Textes;
|
||||
|
||||
namespace MaBibli.Api.Services.Isbn;
|
||||
|
||||
/// <summary>Une notice BnF réduite à ce qui sert à bâtir une bibliographie.</summary>
|
||||
public sealed record NoticeAuteur
|
||||
{
|
||||
public required string Titre { get; init; }
|
||||
|
||||
/// <summary>Clé d'œuvre du titre : c'est elle qui réunit les rééditions.</summary>
|
||||
public required string CleTitre { get; init; }
|
||||
|
||||
public string? Annee { get; init; }
|
||||
|
||||
public string? Editeur { get; init; }
|
||||
|
||||
public string? Isbn { get; init; }
|
||||
|
||||
/// <summary>Auteurs nettoyés de la notice, tous rôles confondus.</summary>
|
||||
public IReadOnlyList<string> Auteurs { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>Résultat de la lecture d'une réponse SRU « par auteur ».</summary>
|
||||
public sealed record ResultatBibliographie
|
||||
{
|
||||
public IReadOnlyList<NoticeAuteur> Notices { get; init; } = [];
|
||||
|
||||
/// <summary>Nombre de notices annoncé par la BnF, tous types confondus.</summary>
|
||||
public int NombreAnnonce { get; init; }
|
||||
|
||||
/// <summary>Notices lues dans cette réponse, avant tout filtrage.</summary>
|
||||
public int NombreLu { get; init; }
|
||||
|
||||
/// <summary>Notices écartées parce qu'elles ne décrivent pas un livre (disque, jeu, film…).</summary>
|
||||
public int EcarteesTypeNonLivre { get; init; }
|
||||
|
||||
/// <summary>Notices écartées parce qu'aucun de leurs auteurs n'est celui demandé.</summary>
|
||||
public int EcarteesAutreAuteur { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lecture d'une réponse SRU de la BnF interrogée <b>par auteur</b>
|
||||
/// (<c>bib.author all "…"</c>, <c>recordSchema=dublincore</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Fonction pure, testée sur des réponses réelles enregistrées dans <c>Fixtures/</c>.
|
||||
/// <para>
|
||||
/// <b>Deux filtres, mesurés sur des réponses réelles le 2026-08-18, sont indispensables.</b>
|
||||
/// Sans eux l'écran de bibliographie ment à l'utilisateur.
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item>
|
||||
/// <b>Le type de document.</b> L'index auteur ne distingue pas un livre d'un livre audio, d'un
|
||||
/// jeu vidéo ou d'un spectacle. Sur 200 notices de Bernard Werber : 180 imprimés, mais aussi
|
||||
/// 13 enregistrements sonores, 3 images animées, 2 jeux multimédias et 1 spectacle. Une liste
|
||||
/// d'envies de livres n'a pas à proposer d'acheter un jeu.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>L'auteur réel de la notice.</b> C'est le filtre <b>critique</b>, et il n'est pas
|
||||
/// théorique. <c>bib.author all "Émile Zola"</c> rapproche les mots « émile » et « zola » sur
|
||||
/// l'<i>ensemble</i> des auteurs d'une notice : un livre signé d'un « Émile » quelconque et d'un
|
||||
/// « Zola » quelconque remonte donc aussi. Mesuré sur 200 notices d'« Émile Zola » : <b>67 sont
|
||||
/// écartées</b>, dont toute l'œuvre de sa fille Denise Le Blond-Zola (« Émile Zola raconté par
|
||||
/// sa fille », « Zola et Cézanne »), qui parle de lui sans qu'il l'ait écrite. Les proposer à
|
||||
/// l'achat comme du Zola serait faux.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Ce second filtre a un <b>coût assumé</b> : il écarte aussi les adaptations dont l'auteur n'est
|
||||
/// pas le scénariste — les bandes dessinées tirées de Werber sont signées Corbeyran, et
|
||||
/// disparaissent (17 notices sur 200). C'est le bon compromis : afficher l'œuvre d'un tiers sous
|
||||
/// le nom de l'auteur est une erreur visible et gênante, en omettre une adaptation ne l'est pas.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class BnfBibliographieParser
|
||||
{
|
||||
private static readonly XNamespace Srw = "http://www.loc.gov/zing/srw/";
|
||||
private static readonly XNamespace Dc = "http://purl.org/dc/elements/1.1/";
|
||||
private static readonly XNamespace Xml = "http://www.w3.org/XML/1998/namespace";
|
||||
|
||||
/// <summary>
|
||||
/// Types <c>dc:type</c> (en français) considérés comme des livres.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Liste <b>positive</b> et non liste d'exclusion : les types inconnus sont écartés. Une
|
||||
/// bibliographie tronquée vaut mieux qu'une bibliographie qui propose un DVD.
|
||||
/// </remarks>
|
||||
private static readonly HashSet<string> TypesLivre =
|
||||
[
|
||||
NormalisationTexte.Normaliser("texte imprimé"),
|
||||
NormalisationTexte.Normaliser("ressource électronique"),
|
||||
];
|
||||
|
||||
/// <summary>Préfixe des <c>dc:identifier</c> qui portent un ISBN : « ISBN 2226086366 ».</summary>
|
||||
private const string PrefixeIsbn = "ISBN ";
|
||||
|
||||
/// <summary>
|
||||
/// Convertit une réponse SRU en notices exploitables.
|
||||
/// </summary>
|
||||
/// <param name="xml">Corps de la réponse SRU.</param>
|
||||
/// <param name="auteurDemande">
|
||||
/// Nom de l'auteur dont on veut la bibliographie. Chaque notice doit compter cet auteur
|
||||
/// parmi les siens, au sens de <see cref="RapprochementAuteurs"/> — la même règle que celle
|
||||
/// qui regroupe les auteurs du catalogue, donc tolérante à la casse, aux accents, à l'ordre
|
||||
/// « Nom, Prénom » et aux initiales.
|
||||
/// </param>
|
||||
public static ResultatBibliographie Parser(string xml, string auteurDemande)
|
||||
{
|
||||
var doc = XDocument.Parse(xml);
|
||||
|
||||
var annonce = doc.Descendants(Srw + "numberOfRecords").FirstOrDefault()?.Value;
|
||||
var notices = new List<NoticeAuteur>();
|
||||
int lues = 0, horsType = 0, autreAuteur = 0;
|
||||
|
||||
foreach (var record in doc.Descendants(Srw + "record"))
|
||||
{
|
||||
var data = record.Element(Srw + "recordData");
|
||||
if (data is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
lues++;
|
||||
|
||||
if (!EstUnLivre(data))
|
||||
{
|
||||
horsType++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var auteurs = Tous(data, "creator")
|
||||
.Select(NettoyageIsbd.Auteur)
|
||||
.Where(a => !string.IsNullOrWhiteSpace(a))
|
||||
.Select(a => a!)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (!auteurs.Any(a => RapprochementAuteurs.SontLeMemeAuteur(a, auteurDemande)))
|
||||
{
|
||||
autreAuteur++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var titre = NettoyageIsbd.Titre(Premier(data, "title"));
|
||||
titre = NettoyageIsbd.RetirerAuteurEnFin(titre, auteurs);
|
||||
|
||||
if (titre is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
notices.Add(new NoticeAuteur
|
||||
{
|
||||
Titre = titre,
|
||||
CleTitre = CleOeuvre.Cle(titre),
|
||||
Annee = Premier(data, "date"),
|
||||
Editeur = NettoyageIsbd.Editeur(Premier(data, "publisher")),
|
||||
Isbn = Isbn(data),
|
||||
Auteurs = auteurs,
|
||||
});
|
||||
}
|
||||
|
||||
return new ResultatBibliographie
|
||||
{
|
||||
Notices = notices,
|
||||
NombreAnnonce = int.TryParse(annonce, out var n) ? n : lues,
|
||||
NombreLu = lues,
|
||||
EcarteesTypeNonLivre = horsType,
|
||||
EcarteesAutreAuteur = autreAuteur,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// La notice décrit-elle un livre ?
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Seuls les <c>dc:type</c> en français sont consultés : les variantes anglaises sont plus
|
||||
/// grossières (« text » couvre aussi bien un imprimé qu'un enregistrement transcrit).
|
||||
/// </remarks>
|
||||
private static bool EstUnLivre(XElement data) =>
|
||||
data.Descendants(Dc + "type")
|
||||
.Where(t => (string?)t.Attribute(Xml + "lang") == "fre")
|
||||
.Select(t => NormalisationTexte.Normaliser(t.Value))
|
||||
.Any(TypesLivre.Contains);
|
||||
|
||||
/// <summary>
|
||||
/// ISBN de la notice, extrait des <c>dc:identifier</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Le Dublin Core BnF mélange dans ce champ l'ark du catalogue et, quand il existe, l'ISBN
|
||||
/// préfixé du mot « ISBN ». Mesuré : 84 notices sur 100 en portent un, presque toujours sous
|
||||
/// sa forme à 10 chiffres — la BnF indexant l'ISBN tel qu'imprimé (voir CLAUDE.md).
|
||||
/// </remarks>
|
||||
private static string? Isbn(XElement data) =>
|
||||
Tous(data, "identifier")
|
||||
.Where(v => v.StartsWith(PrefixeIsbn, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(v => v[PrefixeIsbn.Length..].Trim())
|
||||
.FirstOrDefault(v => v.Length > 0);
|
||||
|
||||
private static string? Premier(XElement data, string nomLocal) =>
|
||||
Tous(data, nomLocal).FirstOrDefault();
|
||||
|
||||
private static IEnumerable<string> Tous(XElement data, string nomLocal) =>
|
||||
data.Descendants(Dc + nomLocal)
|
||||
.Select(e => e.Value?.Trim())
|
||||
.Where(v => !string.IsNullOrEmpty(v))
|
||||
.Select(v => v!);
|
||||
}
|
||||
@@ -11,6 +11,16 @@ public interface IBnfClient
|
||||
/// </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>
|
||||
@@ -59,4 +69,100 @@ public sealed class BnfClient(HttpClient http, ILogger<BnfClient> logger) : IBnf
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user