Files
mabibli/MaBibli.Api/Services/Isbn/BnfBibliographieParser.cs
T
mathieuandClaude Opus 5 8b8fe3be6e 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>
2026-08-18 13:59:06 +02:00

210 lines
8.3 KiB
C#

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!);
}