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