using System.Xml.Linq; using MaBibli.Shared.Dtos; using MaBibli.Shared.Isbn; using MaBibli.Shared.Textes; namespace MaBibli.Api.Services.Isbn; /// Une notice BnF réduite à ce qui sert à bâtir une bibliographie. public sealed record NoticeAuteur { public required string Titre { get; init; } /// Clé d'œuvre du titre : c'est elle qui réunit les rééditions. public required string CleTitre { get; init; } public string? Annee { get; init; } public string? Editeur { get; init; } public string? Isbn { get; init; } /// Auteurs nettoyés de la notice, tous rôles confondus. public IReadOnlyList Auteurs { get; init; } = []; } /// Résultat de la lecture d'une réponse SRU « par auteur ». public sealed record ResultatBibliographie { public IReadOnlyList Notices { get; init; } = []; /// Nombre de notices annoncé par la BnF, tous types confondus. public int NombreAnnonce { get; init; } /// Notices lues dans cette réponse, avant tout filtrage. public int NombreLu { get; init; } /// Notices écartées parce qu'elles ne décrivent pas un livre (disque, jeu, film…). public int EcarteesTypeNonLivre { get; init; } /// Notices écartées parce qu'aucun de leurs auteurs n'est celui demandé. public int EcarteesAutreAuteur { get; init; } /// /// Ce qu'il est advenu de l'interrogation. ⚠️ À ne pas confondre avec « aucune notice » : /// une source muette rend elle aussi une liste vide, et il ne faut surtout pas les lire /// de la même façon. /// public EtatSourceBibliographie Etat { get; init; } } /// /// Lecture d'une réponse SRU de la BnF interrogée par auteur /// (bib.author all "…", recordSchema=dublincore). /// /// /// Fonction pure, testée sur des réponses réelles enregistrées dans Fixtures/. /// /// Deux filtres, mesurés sur des réponses réelles le 2026-08-18, sont indispensables. /// Sans eux l'écran de bibliographie ment à l'utilisateur. /// /// /// /// Le type de document. 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. /// /// /// L'auteur réel de la notice. C'est le filtre critique, et il n'est pas /// théorique. bib.author all "Émile Zola" rapproche les mots « émile » et « zola » sur /// l'ensemble 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 » : 67 sont /// écartées, 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. /// /// /// /// Ce second filtre a un coût assumé : 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. /// /// 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"; /// /// Types dc:type (en français) considérés comme des livres. /// /// /// Liste positive et non liste d'exclusion : les types inconnus sont écartés. Une /// bibliographie tronquée vaut mieux qu'une bibliographie qui propose un DVD. /// private static readonly HashSet TypesLivre = [ NormalisationTexte.Normaliser("texte imprimé"), NormalisationTexte.Normaliser("ressource électronique"), ]; /// Préfixe des dc:identifier qui portent un ISBN : « ISBN 2226086366 ». private const string PrefixeIsbn = "ISBN "; /// /// Convertit une réponse SRU en notices exploitables. /// /// Corps de la réponse SRU. /// /// Nom de l'auteur dont on veut la bibliographie. Chaque notice doit compter cet auteur /// parmi les siens, au sens de — 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. /// 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(); 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, }; } /// /// Convertit une réponse SRU interrogée par titre en candidats à choisir. /// /// /// Le filtre de type est indispensable ici aussi, et pour la même raison qu'en /// bibliographie : mesuré le 2026-08-18, bib.title all "la bête humaine" croisé avec /// Zola remonte 116 notices, dont les films de Jean Renoir (« image animée ») et une /// plaquette de présentation. Une liste d'envies de livres n'a pas à proposer un DVD. /// /// ⚠️ En revanche aucun post-filtre sur l'auteur, contrairement à /// : la contrainte de titre resserre déjà beaucoup, l'auteur est /// facultatif dans cette recherche, et surtout l'utilisateur choisit dans la liste /// en voyant les auteurs de chaque notice. Écarter d'office lui cacherait des éditions /// légitimes qu'il aurait su reconnaître. /// /// /// L'ISBN est repris de la notice quand elle en porte un : c'est lui qui donne la /// couverture, la BnF n'en fournissant aucune. /// /// public static IReadOnlyList ParserRecherche(string xml) { var doc = XDocument.Parse(xml); var candidats = new List(); foreach (var record in doc.Descendants(Srw + "record")) { var data = record.Element(Srw + "recordData"); if (data is null || !EstUnLivre(data)) { continue; } var auteurs = Tous(data, "creator") .Select(NettoyageIsbd.Auteur) .Where(a => !string.IsNullOrWhiteSpace(a)) .Select(a => a!) .Distinct() .ToList(); var titre = NettoyageIsbd.RetirerAuteurEnFin( NettoyageIsbd.Titre(Premier(data, "title")), auteurs); if (titre is null) { continue; } var isbn = IsbnUtils.Normaliser(Isbn(data)); if (!IsbnUtils.EstValide(isbn)) { isbn = null; } candidats.Add(new CandidatLivre { Titre = titre, Auteurs = auteurs, Editeur = NettoyageIsbd.Editeur(Premier(data, "publisher")), Annee = Premier(data, "date"), Langue = Premier(data, "language"), Source = SourceMetadonnees.Bnf, IsbnInterroge = isbn, CoverUrl = isbn is null ? null : IsbnUtils.UrlCouverture(isbn), IdentifiantSource = record.Element(Srw + "recordIdentifier")?.Value?.Trim(), }); } return candidats; } /// /// La notice décrit-elle un livre ? /// /// /// Seuls les dc:type en français sont consultés : les variantes anglaises sont plus /// grossières (« text » couvre aussi bien un imprimé qu'un enregistrement transcrit). /// 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); /// /// ISBN de la notice, extrait des dc:identifier. /// /// /// 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). /// 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 Tous(XElement data, string nomLocal) => data.Descendants(Dc + nomLocal) .Select(e => e.Value?.Trim()) .Where(v => !string.IsNullOrEmpty(v)) .Select(v => v!); }