Six lots, issus d'IDEES.md. Les décisions actées sont reportées dans
CLAUDE.md, et les entrées traitées retirées d'IDEES.md.
1. Douchette USB, ISSN et enchaînement du scan
- Champ ISBN focalisé à l'ouverture : c'est tout ce qu'exige une
douchette USB, qui se présente au système comme un clavier. Remède
au scan caméra, qui rate sur la webcam d'un PC (optique, pas
décodage).
- Un EAN en 977 est un périodique : il porte un ISSN, donc un titre de
revue. Intercepté AVANT la validation ISBN (c'est un EAN-13
parfaitement valide), l'ISSN est déduit du code puis nommé via
bib.issn. Auparavant la cascade s'exécutait en entier pour finir sur
« aucun résultat ».
- L'enchaînement après scan existait déjà mais était invisible : une
étape « Recherche » affiche désormais le code interrogé.
2. ISBN affichés avec des tirets
Tables extraites du RangeMessage.xml officiel — plusieurs tranches ne
sont pas celles qu'on suppose. Le francophone est découpé en entier,
ailleurs on s'arrête au groupe et à la clé : aucune coupure fausse.
Corrige au passage l'export CSV, où un ISBN nu était lu comme un
nombre par Excel.
3. Hors-ligne : la liste d'envies
ListerSouhaitsAsync était le seul point de lecture hors du dispositif
hors-ligne, d'où le « 404 Not Found » brut à l'écran. Cinquième
instantané, écritures refusées, plus aucun message HTTP. Même défaut
corrigé sur la bibliographie.
4. Navigation par onglets
Catalogue / Auteurs / Prêts / Envies dans MainLayout ; les barres
d'actions ne portent plus que des actions. Filtres repliés derrière un
bouton compteur, ligne « format » masquée quand le fonds n'a qu'un
format. Une seule entrée d'ajout, désactivée hors-ligne — pas masquée.
5. Liste d'envies : ordre, recherche, couvertures
Migration RangDesEnvies. Le remplissage reconduit l'ordre affiché
jusqu'ici : sans lui, les listes existantes se seraient réordonnées
toutes seules. Réordonnancement par flèches et glisser-déposer (le
drag & drop HTML5 ne marche pas au doigt). Ajout dans son propre
écran, avec recherche par titre (bib.title) et couvertures enfin
alimentées.
6. Bibliographie : une source muette n'est pas une liste vide
L'écran affichait « BnF injoignable » PUIS « la BnF ne connaît aucun
livre de cet auteur » — la seconde phrase étant fausse. Les deux cas
s'excluent désormais, et un bouton Réessayer est offert.
Vérifié en exécution : le diagnostic d'IDEES.md était faux sur un
point. Robert A. Harper a bien 7 œuvres à la BnF (85 notices
annoncées) ; c'était le même délai dépassé observé deux fois, pris
pour deux causes distinctes.
380 tests, dont un qui applique réellement la migration (EnsureCreated
n'en joue aucune) et un qui verrouille les messages atteignant
l'utilisateur.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
290 lines
11 KiB
C#
290 lines
11 KiB
C#
using System.Xml.Linq;
|
|
using MaBibli.Shared.Dtos;
|
|
using MaBibli.Shared.Isbn;
|
|
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>
|
|
/// 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.
|
|
/// </summary>
|
|
public EtatSourceBibliographie Etat { 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>
|
|
/// Convertit une réponse SRU interrogée <b>par titre</b> en candidats à choisir.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Le filtre de type est <b>indispensable ici aussi</b>, et pour la même raison qu'en
|
|
/// bibliographie : mesuré le 2026-08-18, <c>bib.title all "la bête humaine"</c> 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.
|
|
/// <para>
|
|
/// ⚠️ En revanche <b>aucun post-filtre sur l'auteur</b>, contrairement à
|
|
/// <see cref="Parser"/> : la contrainte de titre resserre déjà beaucoup, l'auteur est
|
|
/// facultatif dans cette recherche, et surtout l'utilisateur <b>choisit dans la liste</b>
|
|
/// en voyant les auteurs de chaque notice. Écarter d'office lui cacherait des éditions
|
|
/// légitimes qu'il aurait su reconnaître.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static IReadOnlyList<CandidatLivre> ParserRecherche(string xml)
|
|
{
|
|
var doc = XDocument.Parse(xml);
|
|
var candidats = new List<CandidatLivre>();
|
|
|
|
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;
|
|
}
|
|
|
|
/// <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!);
|
|
}
|