MaBibli 1.0.0
Gestion de bibliothèque personnelle auto-hébergée : catalogue, prêts, scan de code-barres, consultation hors-ligne. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
using System.Xml.Linq;
|
||||
using MaBibli.Shared.Dtos;
|
||||
|
||||
namespace MaBibli.Api.Services.Isbn;
|
||||
|
||||
/// <summary>
|
||||
/// Lecture d'une réponse SRU de la BnF au schéma <c>dublincore</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Fonction pure : aucun accès réseau, ce qui permet de la tester sur des réponses réelles
|
||||
/// enregistrées sur disque.
|
||||
/// </remarks>
|
||||
public static class BnfSruParser
|
||||
{
|
||||
private static readonly XNamespace Srw = "http://www.loc.gov/zing/srw/";
|
||||
private static readonly XNamespace Dc = "http://purl.org/dc/elements/1.1/";
|
||||
|
||||
/// <summary>Nombre de notices annoncé par <c><srw:numberOfRecords></c>, ou 0.</summary>
|
||||
public static int LireNombreDeNotices(string xml)
|
||||
{
|
||||
var doc = XDocument.Parse(xml);
|
||||
var valeur = doc.Descendants(Srw + "numberOfRecords").FirstOrDefault()?.Value;
|
||||
return int.TryParse(valeur, out var n) ? n : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convertit la réponse SRU en candidats, champs déjà nettoyés de la ponctuation ISBD.
|
||||
/// </summary>
|
||||
/// <param name="xml">Corps de la réponse SRU.</param>
|
||||
/// <param name="isbnInterroge">Forme d'ISBN qui a produit cette réponse (13 ou 10).</param>
|
||||
/// <param name="urlCouverture">URL de couverture OpenLibrary à attacher (la BnF n'en fournit pas).</param>
|
||||
public static IReadOnlyList<CandidatLivre> Parser(string xml, string isbnInterroge, string? urlCouverture)
|
||||
{
|
||||
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)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Plusieurs dc:creator sont possibles : un livre peut avoir plusieurs auteurs.
|
||||
var auteurs = Tous(data, "creator")
|
||||
.Select(NettoyageIsbd.Auteur)
|
||||
.Where(a => a is not null)
|
||||
.Select(a => a!)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var titre = NettoyageIsbd.Titre(Premier(data, "title"));
|
||||
|
||||
// Certaines notices recollent le nom de l'auteur en fin de titre sans le séparateur
|
||||
// ISBD — il faut donc les auteurs nettoyés avant de pouvoir finir le titre.
|
||||
titre = NettoyageIsbd.RetirerAuteurEnFin(titre, auteurs);
|
||||
|
||||
if (titre is null)
|
||||
{
|
||||
// Une notice sans titre exploitable n'a aucun intérêt pour un pré-remplissage.
|
||||
continue;
|
||||
}
|
||||
|
||||
var identifiant = record.Element(Srw + "recordIdentifier")?.Value?.Trim();
|
||||
|
||||
candidats.Add(new CandidatLivre
|
||||
{
|
||||
Titre = titre,
|
||||
Auteurs = auteurs,
|
||||
Editeur = NettoyageIsbd.Editeur(Premier(data, "publisher")),
|
||||
Annee = Premier(data, "date"),
|
||||
Langue = Premier(data, "language"),
|
||||
|
||||
// ⚠️ Plusieurs dc:format cohabitent sur une notice (« 1 vol. (349 p.) », puis
|
||||
// « 30 cm ») : on prend le PREMIER qui donne une pagination, et non le premier
|
||||
// tout court, qui pourrait n'être qu'une dimension.
|
||||
NombrePages = Tous(data, "format")
|
||||
.Select(NettoyageIsbd.NombreDePages)
|
||||
.FirstOrDefault(pages => pages is not null),
|
||||
CoverUrl = urlCouverture,
|
||||
Source = SourceMetadonnees.Bnf,
|
||||
IsbnInterroge = isbnInterroge,
|
||||
IdentifiantSource = identifiant,
|
||||
UrlNotice = UrlNotice(identifiant),
|
||||
});
|
||||
}
|
||||
|
||||
return candidats;
|
||||
}
|
||||
|
||||
private static string? UrlNotice(string? identifiant) =>
|
||||
string.IsNullOrWhiteSpace(identifiant)
|
||||
? null
|
||||
: $"https://catalogue.bnf.fr/{identifiant}";
|
||||
|
||||
/// <summary>
|
||||
/// Lit la <b>première</b> notice d'une réponse SRU interrogée par <c>bib.issn</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// On ne renvoie qu'une notice, et sans laisser le choix : le but n'est pas de cataloguer
|
||||
/// la revue — le modèle de données ne le permet pas — mais de <b>nommer</b> ce que
|
||||
/// l'utilisateur vient de scanner. Un ISSN désigne un et un seul titre de périodique, donc
|
||||
/// les notices suivantes ne diraient rien de plus.
|
||||
/// <para>
|
||||
/// Une notice de revue porte souvent <b>deux</b> <c>dc:title</c> : le titre clé qualifié
|
||||
/// (« Médor (Bruxelles) ») puis le titre développé avec sa mention de responsabilité.
|
||||
/// C'est le premier qu'on retient — le second est l'accroche commerciale, pas le nom.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <returns><c>null</c> si la réponse ne contient aucune notice exploitable.</returns>
|
||||
public static PeriodiqueDetecte? ParserPeriodique(string xml, string issn)
|
||||
{
|
||||
var doc = XDocument.Parse(xml);
|
||||
|
||||
foreach (var record in doc.Descendants(Srw + "record"))
|
||||
{
|
||||
var data = record.Element(Srw + "recordData");
|
||||
if (data is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var titre = NettoyageIsbd.TitrePeriodique(Premier(data, "title"));
|
||||
if (titre is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return new PeriodiqueDetecte
|
||||
{
|
||||
Issn = issn,
|
||||
Titre = titre,
|
||||
Editeur = NettoyageIsbd.Editeur(Premier(data, "publisher")),
|
||||
Annee = Premier(data, "date"),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? Premier(XElement recordData, string nomLocal) =>
|
||||
Tous(recordData, nomLocal).FirstOrDefault();
|
||||
|
||||
private static IEnumerable<string> Tous(XElement recordData, string nomLocal) =>
|
||||
recordData.Descendants(Dc + nomLocal)
|
||||
.Select(e => e.Value?.Trim())
|
||||
.Where(v => !string.IsNullOrEmpty(v))
|
||||
.Select(v => v!);
|
||||
}
|
||||
Reference in New Issue
Block a user