From f8f30dbcf77fa57a4c5b3958cede8f3fff7da490 Mon Sep 17 00:00:00 2001 From: mathieu Date: Mon, 17 Aug 2026 22:22:47 +0200 Subject: [PATCH] Phase 2 : service de lookup ISBN en cascade BnF puis OpenLibrary Recuperation des metadonnees d'un livre a partir de son ISBN, cote serveur uniquement (aucune interface, aucune persistance). Cascade : BnF en ISBN-13, puis BnF en ISBN-10 converti, puis OpenLibrary. La conversion 13 -> 10 est indispensable et non optionnelle : la BnF indexe l'ISBN tel qu'imprime, les ouvrages d'avant 2007 ne portent qu'un ISBN-10 et sont introuvables par l'EAN-13 que lit le scanner. Toutes les notices trouvees sont remontees (jusqu'a 5) : un meme ISBN peut correspondre a plusieurs reeditions, le choix revient a l'utilisateur. Le double appel /isbn puis /authors d'OpenLibrary est bien fait, avec repli sur l'oeuvre quand l'edition ne porte aucun auteur : c'est le bug de BookLogr (titre rempli, auteur vide) qu'il ne faut pas reproduire. La couverture vient toujours d'OpenLibrary, avec ?default=false pour obtenir un 404 plutot qu'une image placeholder. 87 tests xUnit, sur fixtures enregistrees : aucune dependance au reseau. Co-Authored-By: Claude Opus 5 --- MaBibli.Api/Endpoints/IsbnEndpoints.cs | 37 ++++ MaBibli.Api/Program.cs | 24 +++ MaBibli.Api/Services/Isbn/BnfClient.cs | 62 ++++++ MaBibli.Api/Services/Isbn/BnfSruParser.cs | 74 +++++++ .../Services/Isbn/IsbnLookupService.cs | 86 ++++++++ MaBibli.Api/Services/Isbn/NettoyageIsbd.cs | 156 +++++++++++++++ .../Services/Isbn/OpenLibraryClient.cs | 108 ++++++++++ .../Services/Isbn/OpenLibraryMapper.cs | 107 ++++++++++ .../Services/Isbn/OpenLibraryModeles.cs | 71 +++++++ MaBibli.Shared/Dtos/CandidatLivre.cs | 47 +++++ MaBibli.Shared/Dtos/ResultatLookupIsbn.cs | 26 +++ MaBibli.Shared/Isbn/IsbnUtils.cs | 144 ++++++++++++++ MaBibli.Tests/BnfSruParserTests.cs | 84 ++++++++ MaBibli.Tests/Fixture.cs | 15 ++ MaBibli.Tests/Fixtures/bnf-0262033844.xml | 1 + MaBibli.Tests/Fixtures/bnf-2080704095.xml | 24 +++ MaBibli.Tests/Fixtures/bnf-2253004227.xml | 52 +++++ MaBibli.Tests/Fixtures/bnf-9780262033848.xml | 1 + MaBibli.Tests/Fixtures/bnf-9782070612758.xml | 24 +++ MaBibli.Tests/Fixtures/bnf-9782080704092.xml | 1 + MaBibli.Tests/Fixtures/bnf-9782253004226.xml | 1 + .../Fixtures/ol-author-OL1004780A.json | 1 + .../Fixtures/ol-author-OL2633511A.json | 1 + .../Fixtures/ol-author-OL2682480A.json | 1 + .../Fixtures/ol-author-OL31901A.json | 1 + .../Fixtures/ol-author-OL32772A.json | 1 + .../Fixtures/ol-author-OL3328609A.json | 1 + .../Fixtures/ol-isbn-9780262033848.json | 1 + .../Fixtures/ol-isbn-9782070612758.json | 1 + .../Fixtures/ol-isbn-9782253004226.json | 1 + .../Fixtures/ol-work-OL4781294W.json | 1 + MaBibli.Tests/IsbnLookupServiceTests.cs | 185 ++++++++++++++++++ MaBibli.Tests/IsbnUtilsTests.cs | 93 +++++++++ MaBibli.Tests/MaBibli.Tests.csproj | 32 +++ MaBibli.Tests/NettoyageIsbdTests.cs | 74 +++++++ MaBibli.Tests/OpenLibraryMapperTests.cs | 108 ++++++++++ MaBibli.sln | 14 ++ 37 files changed, 1661 insertions(+) create mode 100644 MaBibli.Api/Endpoints/IsbnEndpoints.cs create mode 100644 MaBibli.Api/Services/Isbn/BnfClient.cs create mode 100644 MaBibli.Api/Services/Isbn/BnfSruParser.cs create mode 100644 MaBibli.Api/Services/Isbn/IsbnLookupService.cs create mode 100644 MaBibli.Api/Services/Isbn/NettoyageIsbd.cs create mode 100644 MaBibli.Api/Services/Isbn/OpenLibraryClient.cs create mode 100644 MaBibli.Api/Services/Isbn/OpenLibraryMapper.cs create mode 100644 MaBibli.Api/Services/Isbn/OpenLibraryModeles.cs create mode 100644 MaBibli.Shared/Dtos/CandidatLivre.cs create mode 100644 MaBibli.Shared/Dtos/ResultatLookupIsbn.cs create mode 100644 MaBibli.Shared/Isbn/IsbnUtils.cs create mode 100644 MaBibli.Tests/BnfSruParserTests.cs create mode 100644 MaBibli.Tests/Fixture.cs create mode 100644 MaBibli.Tests/Fixtures/bnf-0262033844.xml create mode 100644 MaBibli.Tests/Fixtures/bnf-2080704095.xml create mode 100644 MaBibli.Tests/Fixtures/bnf-2253004227.xml create mode 100644 MaBibli.Tests/Fixtures/bnf-9780262033848.xml create mode 100644 MaBibli.Tests/Fixtures/bnf-9782070612758.xml create mode 100644 MaBibli.Tests/Fixtures/bnf-9782080704092.xml create mode 100644 MaBibli.Tests/Fixtures/bnf-9782253004226.xml create mode 100644 MaBibli.Tests/Fixtures/ol-author-OL1004780A.json create mode 100644 MaBibli.Tests/Fixtures/ol-author-OL2633511A.json create mode 100644 MaBibli.Tests/Fixtures/ol-author-OL2682480A.json create mode 100644 MaBibli.Tests/Fixtures/ol-author-OL31901A.json create mode 100644 MaBibli.Tests/Fixtures/ol-author-OL32772A.json create mode 100644 MaBibli.Tests/Fixtures/ol-author-OL3328609A.json create mode 100644 MaBibli.Tests/Fixtures/ol-isbn-9780262033848.json create mode 100644 MaBibli.Tests/Fixtures/ol-isbn-9782070612758.json create mode 100644 MaBibli.Tests/Fixtures/ol-isbn-9782253004226.json create mode 100644 MaBibli.Tests/Fixtures/ol-work-OL4781294W.json create mode 100644 MaBibli.Tests/IsbnLookupServiceTests.cs create mode 100644 MaBibli.Tests/IsbnUtilsTests.cs create mode 100644 MaBibli.Tests/MaBibli.Tests.csproj create mode 100644 MaBibli.Tests/NettoyageIsbdTests.cs create mode 100644 MaBibli.Tests/OpenLibraryMapperTests.cs diff --git a/MaBibli.Api/Endpoints/IsbnEndpoints.cs b/MaBibli.Api/Endpoints/IsbnEndpoints.cs new file mode 100644 index 0000000..d909468 --- /dev/null +++ b/MaBibli.Api/Endpoints/IsbnEndpoints.cs @@ -0,0 +1,37 @@ +using MaBibli.Api.Services.Isbn; +using MaBibli.Shared.Dtos; + +namespace MaBibli.Api.Endpoints; + +public static class IsbnEndpoints +{ + /// + /// Expose le lookup ISBN. Aucune écriture en base : cet endpoint pré-remplit un formulaire, + /// il n'enregistre rien. + /// + public static IEndpointRouteBuilder MapIsbnEndpoints(this IEndpointRouteBuilder routes) + { + var groupe = routes.MapGroup("/api/isbn").WithTags("ISBN"); + + groupe.MapGet("/{isbn}", async ( + string isbn, + IIsbnLookupService service, + CancellationToken ct) => + { + var resultat = await service.RechercherAsync(isbn, ct); + + return resultat is null + ? Results.BadRequest(new { erreur = $"« {isbn} » n'est pas un ISBN valide." }) + : Results.Ok(resultat); + }) + .WithName("RechercherIsbn") + .WithSummary("Cherche les notices correspondant à un ISBN (cascade BnF → OpenLibrary).") + .WithDescription( + "Renvoie TOUS les candidats trouvés (jusqu'à 5) : un même ISBN peut correspondre à " + + "plusieurs rééditions. Le choix revient à l'utilisateur.") + .Produces() + .Produces(StatusCodes.Status400BadRequest); + + return routes; + } +} diff --git a/MaBibli.Api/Program.cs b/MaBibli.Api/Program.cs index 6cd362e..d143c55 100644 --- a/MaBibli.Api/Program.cs +++ b/MaBibli.Api/Program.cs @@ -1,4 +1,6 @@ using MaBibli.Api.Data; +using MaBibli.Api.Endpoints; +using MaBibli.Api.Services.Isbn; using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); @@ -8,6 +10,26 @@ builder.Services.AddOpenApi(); builder.Services.AddDbContext(options => options.UseSqlite(builder.Configuration.GetConnectionString("MaBibli"))); +// Lookup ISBN : clients typés via IHttpClientFactory (pas de HttpClient instancié à la main). +// Timeout court : une source lente ne doit pas bloquer la cascade, l'appelant bascule sur la suivante. +var timeoutSources = TimeSpan.FromSeconds(10); + +builder.Services.AddHttpClient(http => +{ + http.BaseAddress = new Uri(BnfClient.UrlBase); + http.Timeout = timeoutSources; + http.DefaultRequestHeaders.UserAgent.ParseAdd("MaBibli/0.1 (bibliotheque personnelle auto-hebergee)"); +}); + +builder.Services.AddHttpClient(http => +{ + http.BaseAddress = new Uri(OpenLibraryClient.UrlBase); + http.Timeout = timeoutSources; + http.DefaultRequestHeaders.UserAgent.ParseAdd("MaBibli/0.1 (bibliotheque personnelle auto-hebergee)"); +}); + +builder.Services.AddScoped(); + var app = builder.Build(); if (app.Environment.IsDevelopment()) @@ -20,6 +42,8 @@ if (app.Environment.IsDevelopment()) app.UseBlazorFrameworkFiles(); app.UseStaticFiles(); +app.MapIsbnEndpoints(); + app.MapFallbackToFile("index.html"); app.Run(); diff --git a/MaBibli.Api/Services/Isbn/BnfClient.cs b/MaBibli.Api/Services/Isbn/BnfClient.cs new file mode 100644 index 0000000..1bdce1d --- /dev/null +++ b/MaBibli.Api/Services/Isbn/BnfClient.cs @@ -0,0 +1,62 @@ +using MaBibli.Shared.Dtos; + +namespace MaBibli.Api.Services.Isbn; + +public interface IBnfClient +{ + /// + /// Interroge le SRU de la BnF pour une forme d'ISBN donnée. + /// Renvoie une liste vide si la BnF ne connaît pas l'ISBN ou si elle est injoignable ; + /// dans ce second cas explique pourquoi. + /// + Task<(IReadOnlyList Candidats, string? Avertissement)> RechercherAsync( + string isbn, string? urlCouverture, CancellationToken ct = default); +} + +/// +/// Client de l'API SRU du catalogue général de la BnF (gratuite, sans clé). +/// +public sealed class BnfClient(HttpClient http, ILogger logger) : IBnfClient +{ + /// Nom du client typé enregistré dans IHttpClientFactory. + public const string NomHttpClient = "bnf"; + + public const string UrlBase = "https://catalogue.bnf.fr/"; + + public async Task<(IReadOnlyList Candidats, string? Avertissement)> RechercherAsync( + string isbn, string? urlCouverture, CancellationToken ct = default) + { + // recordSchema=dublincore et non MARC : dc:title / dc:creator / dc:publisher se mappent + // directement, là où l'UNIMARC demanderait un décodage complet. + var url = "api/SRU" + + "?version=1.2" + + "&operation=searchRetrieve" + + $"&query={Uri.EscapeDataString($"bib.isbn all \"{isbn}\"")}" + + "&recordSchema=dublincore" + + "&maximumRecords=5"; + + try + { + using var reponse = await http.GetAsync(url, ct); + if (!reponse.IsSuccessStatusCode) + { + logger.LogWarning("BnF a répondu {Code} pour l'ISBN {Isbn}", (int)reponse.StatusCode, isbn); + return ([], $"La BnF a répondu {(int)reponse.StatusCode} pour {isbn}."); + } + + var xml = await reponse.Content.ReadAsStringAsync(ct); + return (BnfSruParser.Parser(xml, isbn, urlCouverture), null); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + // Une source indisponible ne doit pas faire échouer la cascade : on bascule sur la suivante. + logger.LogWarning(ex, "BnF injoignable pour l'ISBN {Isbn}", isbn); + return ([], $"BnF injoignable ({ex.GetType().Name}) pour {isbn}."); + } + catch (System.Xml.XmlException ex) + { + logger.LogWarning(ex, "Réponse BnF illisible pour l'ISBN {Isbn}", isbn); + return ([], $"Réponse BnF illisible pour {isbn}."); + } + } +} diff --git a/MaBibli.Api/Services/Isbn/BnfSruParser.cs b/MaBibli.Api/Services/Isbn/BnfSruParser.cs new file mode 100644 index 0000000..5dc22d4 --- /dev/null +++ b/MaBibli.Api/Services/Isbn/BnfSruParser.cs @@ -0,0 +1,74 @@ +using System.Xml.Linq; +using MaBibli.Shared.Dtos; + +namespace MaBibli.Api.Services.Isbn; + +/// +/// Lecture d'une réponse SRU de la BnF au schéma dublincore. +/// +/// +/// Fonction pure : aucun accès réseau, ce qui permet de la tester sur des réponses réelles +/// enregistrées sur disque. +/// +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/"; + + /// Nombre de notices annoncé par <srw:numberOfRecords>, ou 0. + 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; + } + + /// + /// Convertit la réponse SRU en candidats, champs déjà nettoyés de la ponctuation ISBD. + /// + /// Corps de la réponse SRU. + /// Forme d'ISBN qui a produit cette réponse (13 ou 10). + /// URL de couverture OpenLibrary à attacher (la BnF n'en fournit pas). + public static IReadOnlyList Parser(string xml, string isbnInterroge, string? urlCouverture) + { + 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) + { + continue; + } + + var titre = NettoyageIsbd.Titre(Premier(data, "title")); + if (titre is null) + { + // Une notice sans titre exploitable n'a aucun intérêt pour un pré-remplissage. + continue; + } + + candidats.Add(new CandidatLivre + { + Titre = titre, + Auteur = NettoyageIsbd.Auteur(Premier(data, "creator")), + Editeur = NettoyageIsbd.Editeur(Premier(data, "publisher")), + Annee = Premier(data, "date"), + Langue = Premier(data, "language"), + CoverUrl = urlCouverture, + Source = SourceMetadonnees.Bnf, + IsbnInterroge = isbnInterroge, + IdentifiantSource = record.Element(Srw + "recordIdentifier")?.Value?.Trim(), + }); + } + + return candidats; + } + + private static string? Premier(XElement recordData, string nomLocal) + { + var valeur = recordData.Descendants(Dc + nomLocal).FirstOrDefault()?.Value?.Trim(); + return string.IsNullOrEmpty(valeur) ? null : valeur; + } +} diff --git a/MaBibli.Api/Services/Isbn/IsbnLookupService.cs b/MaBibli.Api/Services/Isbn/IsbnLookupService.cs new file mode 100644 index 0000000..a236753 --- /dev/null +++ b/MaBibli.Api/Services/Isbn/IsbnLookupService.cs @@ -0,0 +1,86 @@ +using MaBibli.Shared.Dtos; +using MaBibli.Shared.Isbn; + +namespace MaBibli.Api.Services.Isbn; + +public interface IIsbnLookupService +{ + /// + /// Cherche toutes les notices correspondant à un ISBN. + /// + /// null si l'ISBN fourni n'est pas un ISBN valide. + Task RechercherAsync(string isbnBrut, CancellationToken ct = default); +} + +/// +/// Cascade de lookup ISBN : BnF d'abord, OpenLibrary ensuite. +/// +/// +/// L'ordre est dicté par CLAUDE.md : la collection est majoritairement francophone et le dépôt +/// légal français donne à la BnF la meilleure couverture possible sur ce fonds, là où OpenLibrary +/// est lacunaire. OpenLibrary n'est interrogée que si la BnF ne renvoie rien sous +/// aucune des deux formes d'ISBN. +/// +public sealed class IsbnLookupService( + IBnfClient bnf, + IOpenLibraryClient openLibrary, + ILogger logger) : IIsbnLookupService +{ + public async Task RechercherAsync(string isbnBrut, CancellationToken ct = default) + { + var isbn = IsbnUtils.Normaliser(isbnBrut); + if (!IsbnUtils.EstValide(isbn)) + { + return null; + } + + // La couverture ne vient jamais de la BnF : son Dublin Core n'en fournit aucune. + // On construit l'URL OpenLibrary quelle que soit la source des métadonnées. + var couverture = IsbnUtils.UrlCouverture(isbn!); + + IsbnUtils.TryConvertirEnIsbn10(isbn, out var isbn10); + + var avertissements = new List(); + + // 1. BnF avec l'ISBN tel que scanné. + var (candidats, avertissement) = await bnf.RechercherAsync(isbn!, couverture, ct); + Ajouter(avertissements, avertissement); + + // 2. BnF avec l'ISBN-10 converti. Indispensable : la BnF indexe l'ISBN tel qu'imprimé, + // et les ouvrages d'avant 2007 ne portent qu'un ISBN-10. + if (candidats.Count == 0 && isbn10 is not null) + { + var (candidats10, avertissement10) = await bnf.RechercherAsync(isbn10, couverture, ct); + Ajouter(avertissements, avertissement10); + candidats = candidats10; + } + + // 3. OpenLibrary en second rideau, seulement si la BnF n'a rien donné du tout. + if (candidats.Count == 0) + { + var (candidatsOl, avertissementOl) = await openLibrary.RechercherAsync(isbn!, couverture, ct); + Ajouter(avertissements, avertissementOl); + candidats = candidatsOl; + } + + logger.LogInformation( + "Lookup ISBN {Isbn} : {Nombre} candidat(s), {Avertissements} avertissement(s)", + isbn, candidats.Count, avertissements.Count); + + return new ResultatLookupIsbn + { + IsbnDemande = isbn!, + Isbn10 = isbn10, + Candidats = candidats, + Avertissements = avertissements, + }; + } + + private static void Ajouter(List avertissements, string? message) + { + if (!string.IsNullOrEmpty(message)) + { + avertissements.Add(message); + } + } +} diff --git a/MaBibli.Api/Services/Isbn/NettoyageIsbd.cs b/MaBibli.Api/Services/Isbn/NettoyageIsbd.cs new file mode 100644 index 0000000..2382b14 --- /dev/null +++ b/MaBibli.Api/Services/Isbn/NettoyageIsbd.cs @@ -0,0 +1,156 @@ +using System.Text.RegularExpressions; + +namespace MaBibli.Api.Services.Isbn; + +/// +/// Nettoyage de la ponctuation ISBD des champs Dublin Core de la BnF. +/// +/// +/// Les champs BnF ne sont pas exploitables bruts. Règles validées le 2026-08-17 (CLAUDE.md) : +/// +/// dc:title : Germinal / Émile Zola ; préface…Germinal +/// dc:creator : Zola, Émile (1840-1902). Auteur du texteÉmile Zola +/// dc:publisher : le Livre de poche (Paris)le Livre de poche +/// +/// Les valeurs déjà propres doivent traverser ces règles inchangées. +/// +public static partial class NettoyageIsbd +{ + [GeneratedRegex(@"\s*\([^)]*\)")] + private static partial Regex Parentheses(); + + [GeneratedRegex(@"\s*\([^)]*\)\s*$")] + private static partial Regex ParenthesesFinales(); + + /// + /// Titre : couper à la première mention de responsabilité (« / »). + /// + public static string? Titre(string? brut) + { + var s = brut?.Trim(); + if (string.IsNullOrEmpty(s)) + { + return null; + } + + var i = s.IndexOf(" / ", StringComparison.Ordinal); + if (i >= 0) + { + s = s[..i]; + } + + s = s.Trim(); + return s.Length == 0 ? null : s; + } + + /// + /// Auteur : retirer les dates entre parenthèses, le rôle qui suit le point, + /// puis inverser « Nom, Prénom » en « Prénom Nom ». + /// + public static string? Auteur(string? brut) + { + var s = brut?.Trim(); + if (string.IsNullOrEmpty(s)) + { + return null; + } + + // 1. Dates de vie et autres qualificatifs entre parenthèses : (1840-1902), (1932-....) + s = Parentheses().Replace(s, string.Empty).Trim(); + + // 2. Rôle après le point : « . Auteur du texte », « . Préfacier ». + var pointRole = IndexDuPointDeRole(s); + if (pointRole >= 0) + { + s = s[..pointRole]; + } + + s = RetirerPonctuationFinale(s); + + // 3. Inversion « Nom, Prénom » → « Prénom Nom » (sur la première virgule seulement). + var virgule = s.IndexOf(", ", StringComparison.Ordinal); + if (virgule > 0) + { + var nom = s[..virgule].Trim(); + var prenom = s[(virgule + 2)..].Trim(); + if (nom.Length > 0 && prenom.Length > 0) + { + s = $"{prenom} {nom}"; + } + } + + s = s.Trim(); + return s.Length == 0 ? null : s; + } + + /// + /// Éditeur : retirer la ville entre parenthèses en fin de chaîne. + /// + public static string? Editeur(string? brut) + { + var s = brut?.Trim(); + if (string.IsNullOrEmpty(s)) + { + return null; + } + + s = ParenthesesFinales().Replace(s, string.Empty).Trim(); + return s.Length == 0 ? null : s; + } + + /// + /// Retire les virgules et espaces de fin, et le point final sauf s'il appartient + /// à une initiale (« Cormen, Thomas H. » doit garder son point). + /// + private static string RetirerPonctuationFinale(string s) + { + s = s.TrimEnd(' ', ','); + + while (s.EndsWith('.')) + { + var i = s.Length - 1; + var estInitiale = i >= 1 + && char.IsUpper(s[i - 1]) + && (i == 1 || s[i - 2] == ' ' || s[i - 2] == '-' || s[i - 2] == '\''); + + if (estInitiale) + { + break; + } + + s = s[..^1].TrimEnd(' ', ','); + } + + return s; + } + + /// + /// Position du point qui introduit le rôle, ou -1. + /// + /// + /// Un point suivi d'une espace sépare le rôle du nom. On ignore les points d'initiales + /// (« Cormen, Thomas H. Auteur ») : un point précédé d'une majuscule isolée n'est pas + /// un séparateur de rôle. + /// + private static int IndexDuPointDeRole(string s) + { + for (var i = 0; i < s.Length - 1; i++) + { + if (s[i] != '.' || s[i + 1] != ' ') + { + continue; + } + + var estInitiale = i >= 1 + && char.IsUpper(s[i - 1]) + && (i == 1 || s[i - 2] == ' ' || s[i - 2] == '-' || s[i - 2] == '\''); + + if (!estInitiale) + { + return i; + } + } + + return -1; + } +} diff --git a/MaBibli.Api/Services/Isbn/OpenLibraryClient.cs b/MaBibli.Api/Services/Isbn/OpenLibraryClient.cs new file mode 100644 index 0000000..f2bafad --- /dev/null +++ b/MaBibli.Api/Services/Isbn/OpenLibraryClient.cs @@ -0,0 +1,108 @@ +using System.Net; +using System.Text.Json; +using MaBibli.Shared.Dtos; + +namespace MaBibli.Api.Services.Isbn; + +public interface IOpenLibraryClient +{ + /// + /// Cherche l'édition correspondant à un ISBN chez OpenLibrary, auteurs résolus. + /// Renvoie une liste vide si l'ISBN est inconnu ou la source injoignable. + /// + Task<(IReadOnlyList Candidats, string? Avertissement)> RechercherAsync( + string isbn, string? urlCouverture, CancellationToken ct = default); +} + +/// +/// Client OpenLibrary — second rideau de la cascade, pour les livres étrangers et tout ce que +/// la BnF ne connaît pas. +/// +/// +/// Bug à ne pas reproduire (observé chez BookLogr) : /isbn/{isbn}.json ne donne pas +/// le nom de l'auteur mais une référence /authors/OL…A. Sans second appel, l'auteur reste +/// vide. Ce client fait ce second appel, et bascule sur l'œuvre rattachée quand l'édition ne +/// porte aucun auteur (cas mesuré : Introduction to Algorithms). +/// +public sealed class OpenLibraryClient(HttpClient http, ILogger logger) : IOpenLibraryClient +{ + public const string NomHttpClient = "openlibrary"; + + public const string UrlBase = "https://openlibrary.org/"; + + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + + public async Task<(IReadOnlyList Candidats, string? Avertissement)> RechercherAsync( + string isbn, string? urlCouverture, CancellationToken ct = default) + { + try + { + // /isbn/{isbn}.json répond en 302 vers /books/OL…M.json quand le livre existe, 404 sinon. + // Le HttpClient suit la redirection par défaut. + var edition = await LireAsync($"isbn/{isbn}.json", ct); + if (edition is null) + { + return ([], null); + } + + var noms = await ResoudreAuteursAsync(edition, ct); + var candidat = OpenLibraryMapper.VersCandidat(edition, noms, isbn, urlCouverture); + return (candidat is null ? [] : [candidat], null); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException) + { + logger.LogWarning(ex, "OpenLibrary injoignable ou illisible pour l'ISBN {Isbn}", isbn); + return ([], $"OpenLibrary injoignable ({ex.GetType().Name}) pour {isbn}."); + } + } + + private async Task> ResoudreAuteursAsync(OpenLibraryEdition edition, CancellationToken ct) + { + var cles = OpenLibraryMapper.ClesAuteurs(edition); + + if (cles.Count == 0) + { + // Repli : certaines éditions n'ont pas de champ `authors`, seule l'œuvre en porte. + var cleOeuvre = OpenLibraryMapper.CleOeuvre(edition); + if (cleOeuvre is not null) + { + var oeuvre = await LireAsync(cleOeuvre.TrimStart('/') + ".json", ct); + if (oeuvre is not null) + { + cles = OpenLibraryMapper.ClesAuteurs(oeuvre); + } + } + } + + var noms = new List(); + foreach (var cle in cles) + { + var auteur = await LireAsync(cle.TrimStart('/') + ".json", ct); + var nom = auteur is null ? null : OpenLibraryMapper.Nom(auteur); + if (nom is not null) + { + noms.Add(nom); + } + } + + return noms; + } + + private async Task LireAsync(string chemin, CancellationToken ct) where T : class + { + using var reponse = await http.GetAsync(chemin, ct); + if (reponse.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + + if (!reponse.IsSuccessStatusCode) + { + logger.LogWarning("OpenLibrary a répondu {Code} pour {Chemin}", (int)reponse.StatusCode, chemin); + return null; + } + + await using var flux = await reponse.Content.ReadAsStreamAsync(ct); + return await JsonSerializer.DeserializeAsync(flux, Json, ct); + } +} diff --git a/MaBibli.Api/Services/Isbn/OpenLibraryMapper.cs b/MaBibli.Api/Services/Isbn/OpenLibraryMapper.cs new file mode 100644 index 0000000..54bcd75 --- /dev/null +++ b/MaBibli.Api/Services/Isbn/OpenLibraryMapper.cs @@ -0,0 +1,107 @@ +using MaBibli.Shared.Dtos; + +namespace MaBibli.Api.Services.Isbn; + +/// +/// Transformation d'une édition OpenLibrary en candidat. Fonctions pures, testables sans réseau. +/// +public static class OpenLibraryMapper +{ + /// + /// Clés d'auteurs portées par l'édition elle-même (/authors/OL…A), éventuellement vide. + /// + /// + /// Ces clés ne sont pas des noms : chacune impose un appel à + /// /authors/{id}.json. Oublier ce second appel donne un titre rempli et un auteur vide + /// — symptôme exact observé chez BookLogr. + /// + public static IReadOnlyList ClesAuteurs(OpenLibraryEdition edition) => + edition.Authors? + .Select(a => a.Key) + .Where(k => !string.IsNullOrWhiteSpace(k)) + .Select(k => k!) + .ToList() ?? []; + + /// Clés d'auteurs portées par l'œuvre, utilisées en repli. + public static IReadOnlyList ClesAuteurs(OpenLibraryWork work) => + work.Authors? + .Select(a => a.Author?.Key) + .Where(k => !string.IsNullOrWhiteSpace(k)) + .Select(k => k!) + .ToList() ?? []; + + /// Clé de l'œuvre rattachée (/works/OL…W), ou null. + public static string? CleOeuvre(OpenLibraryEdition edition) => + edition.Works?.Select(w => w.Key).FirstOrDefault(k => !string.IsNullOrWhiteSpace(k)); + + /// Nom affichable d'un auteur OpenLibrary. + public static string? Nom(OpenLibraryAuthor auteur) + { + var nom = auteur.Name ?? auteur.PersonalName; + return string.IsNullOrWhiteSpace(nom) ? null : nom.Trim(); + } + + /// + /// Assemble le candidat final. vient des appels + /// /authors/{id}.json déjà effectués par le client. + /// + public static CandidatLivre? VersCandidat( + OpenLibraryEdition edition, + IReadOnlyList nomsAuteurs, + string isbnInterroge, + string? urlCouverture) + { + var titre = edition.Title?.Trim(); + if (string.IsNullOrEmpty(titre)) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(edition.Subtitle)) + { + titre = $"{titre} : {edition.Subtitle.Trim()}"; + } + + var auteur = nomsAuteurs.Count > 0 + ? string.Join(", ", nomsAuteurs) + : NettoyerByStatement(edition.ByStatement); + + return new CandidatLivre + { + Titre = titre, + Auteur = auteur, + Editeur = edition.Publishers?.FirstOrDefault()?.Trim().Trim('[', ']'), + Annee = string.IsNullOrWhiteSpace(edition.PublishDate) ? null : edition.PublishDate.Trim(), + Langue = edition.Languages? + .Select(l => l.Key) + .FirstOrDefault(k => !string.IsNullOrWhiteSpace(k))? + .Replace("/languages/", string.Empty), + CoverUrl = urlCouverture, + Source = SourceMetadonnees.OpenLibrary, + IsbnInterroge = isbnInterroge, + IdentifiantSource = edition.Key, + }; + } + + /// + /// Dernier repli quand aucun auteur n'est résolvable : la mention de responsabilité brute, + /// débarrassée de sa ponctuation finale (« Thomas H. Cormen ... [et al.]. »). + /// + private static string? NettoyerByStatement(string? brut) + { + var s = brut?.Trim(); + if (string.IsNullOrEmpty(s)) + { + return null; + } + + var i = s.IndexOf(" ... ", StringComparison.Ordinal); + if (i >= 0) + { + s = s[..i]; + } + + s = s.TrimEnd('.', ' ', ','); + return s.Length == 0 ? null : s; + } +} diff --git a/MaBibli.Api/Services/Isbn/OpenLibraryModeles.cs b/MaBibli.Api/Services/Isbn/OpenLibraryModeles.cs new file mode 100644 index 0000000..914ffdf --- /dev/null +++ b/MaBibli.Api/Services/Isbn/OpenLibraryModeles.cs @@ -0,0 +1,71 @@ +using System.Text.Json.Serialization; + +namespace MaBibli.Api.Services.Isbn; + +/// Réponse de https://openlibrary.org/books/OL…M.json (édition). +public record OpenLibraryEdition +{ + [JsonPropertyName("key")] + public string? Key { get; init; } + + [JsonPropertyName("title")] + public string? Title { get; init; } + + [JsonPropertyName("subtitle")] + public string? Subtitle { get; init; } + + /// + /// Références d'auteurs, pas des noms : { "key": "/authors/OL31901A" }. + /// Il faut un second appel pour obtenir le nom — c'est le bug de BookLogr à ne pas reproduire. + /// Ce champ est parfois totalement absent (cas mesuré : Introduction to Algorithms). + /// + [JsonPropertyName("authors")] + public List? Authors { get; init; } + + /// Œuvre rattachée, utilisée en repli quand l'édition ne porte aucun auteur. + [JsonPropertyName("works")] + public List? Works { get; init; } + + [JsonPropertyName("publishers")] + public List? Publishers { get; init; } + + [JsonPropertyName("publish_date")] + public string? PublishDate { get; init; } + + [JsonPropertyName("languages")] + public List? Languages { get; init; } + + /// Mention de responsabilité brute, dernier repli si aucun auteur n'est résolvable. + [JsonPropertyName("by_statement")] + public string? ByStatement { get; init; } +} + +/// Réponse de https://openlibrary.org/works/OL…W.json. +public record OpenLibraryWork +{ + [JsonPropertyName("authors")] + public List? Authors { get; init; } +} + +public record OpenLibraryWorkAuthor +{ + [JsonPropertyName("author")] + public OpenLibraryCle? Author { get; init; } +} + +/// Réponse de https://openlibrary.org/authors/OL…A.json. +public record OpenLibraryAuthor +{ + [JsonPropertyName("name")] + public string? Name { get; init; } + + [JsonPropertyName("personal_name")] + public string? PersonalName { get; init; } +} + +/// Référence OpenLibrary : { "key": "/authors/OL31901A" }. +public record OpenLibraryCle +{ + [JsonPropertyName("key")] + public string? Key { get; init; } +} diff --git a/MaBibli.Shared/Dtos/CandidatLivre.cs b/MaBibli.Shared/Dtos/CandidatLivre.cs new file mode 100644 index 0000000..06beba7 --- /dev/null +++ b/MaBibli.Shared/Dtos/CandidatLivre.cs @@ -0,0 +1,47 @@ +namespace MaBibli.Shared.Dtos; + +/// Source d'où proviennent les métadonnées d'un candidat. +public enum SourceMetadonnees +{ + /// Catalogue général de la BnF, via son API SRU (Dublin Core). + Bnf = 0, + + /// OpenLibrary (Internet Archive), second rideau de la cascade. + OpenLibrary = 1, +} + +/// +/// Une notice candidate pour un ISBN donné. +/// +/// +/// Un même ISBN peut correspondre à plusieurs notices (rééditions successives partageant +/// l'ISBN). Décision actée dans CLAUDE.md : on ne choisit pas à la place de l'utilisateur, +/// on lui présente tous les candidats. C'est l'interface (phase 3) qui fera choisir. +/// +public record CandidatLivre +{ + /// Titre nettoyé de la ponctuation ISBD. + public required string Titre { get; init; } + + /// Auteur au format « Prénom Nom », ou null si la source n'en donne pas. + public string? Auteur { get; init; } + + public string? Editeur { get; init; } + + /// Année de publication telle que fournie par la source (texte : « 1979 », « March 2007 »…). + public string? Annee { get; init; } + + /// Code langue tel que fourni par la source (« fre », « eng »…). + public string? Langue { get; init; } + + /// URL de la couverture chez OpenLibrary, toujours renseignée (voir remarques du service). + public string? CoverUrl { get; init; } + + public required SourceMetadonnees Source { get; init; } + + /// Forme d'ISBN qui a effectivement produit cette notice (13 ou 10 converti). + public string? IsbnInterroge { get; init; } + + /// Identifiant chez la source : ark BnF (ark:/12148/cb…) ou clé OpenLibrary (/books/OL…M). + public string? IdentifiantSource { get; init; } +} diff --git a/MaBibli.Shared/Dtos/ResultatLookupIsbn.cs b/MaBibli.Shared/Dtos/ResultatLookupIsbn.cs new file mode 100644 index 0000000..0cd5af5 --- /dev/null +++ b/MaBibli.Shared/Dtos/ResultatLookupIsbn.cs @@ -0,0 +1,26 @@ +namespace MaBibli.Shared.Dtos; + +/// +/// Résultat complet d'un lookup ISBN : la liste des candidats trouvés par la cascade +/// BnF → OpenLibrary, plus de quoi expliquer à l'utilisateur ce qui s'est passé. +/// +public record ResultatLookupIsbn +{ + /// ISBN normalisé tel que demandé (tirets et espaces retirés). + public required string IsbnDemande { get; init; } + + /// ISBN-10 dérivé, si la conversion était possible (préfixe 978 uniquement). + public string? Isbn10 { get; init; } + + /// + /// Tous les candidats trouvés, dans l'ordre de la cascade. Peut être vide. + /// La sélection revient à l'utilisateur. + /// + public required IReadOnlyList Candidats { get; init; } + + /// + /// Messages non bloquants : source injoignable, réponse illisible… + /// Une source en panne ne fait pas échouer la cascade, elle laisse une trace ici. + /// + public IReadOnlyList Avertissements { get; init; } = []; +} diff --git a/MaBibli.Shared/Isbn/IsbnUtils.cs b/MaBibli.Shared/Isbn/IsbnUtils.cs new file mode 100644 index 0000000..05bd600 --- /dev/null +++ b/MaBibli.Shared/Isbn/IsbnUtils.cs @@ -0,0 +1,144 @@ +using System.Text; + +namespace MaBibli.Shared.Isbn; + +/// +/// Normalisation, validation et conversion des ISBN. +/// +/// +/// Piège bloquant documenté dans CLAUDE.md : la BnF indexe l'ISBN tel qu'imprimé +/// sur le livre. Les ouvrages parus avant 2007 ne portent qu'un ISBN-10 et sont donc +/// introuvables par leur ISBN-13, alors qu'un scanner de code-barres lit toujours un EAN-13. +/// La conversion 13 → 10 n'est pas une optimisation : sans elle, tout le fonds ancien est perdu. +/// +public static class IsbnUtils +{ + /// + /// Retire tirets, espaces et points ; met le x final en majuscule. + /// Renvoie null si l'entrée est vide. + /// + public static string? Normaliser(string? brut) + { + if (string.IsNullOrWhiteSpace(brut)) + { + return null; + } + + var sb = new StringBuilder(brut.Length); + foreach (var c in brut) + { + if (char.IsDigit(c)) + { + sb.Append(c); + } + else if (c is 'x' or 'X') + { + sb.Append('X'); + } + // Tout le reste (tirets, espaces, points, insécables…) est ignoré. + } + + return sb.Length == 0 ? null : sb.ToString(); + } + + /// Vrai si (déjà normalisé) est un ISBN-10 valide, clé comprise. + public static bool EstIsbn10Valide(string? isbn) + { + if (isbn is not { Length: 10 }) + { + return false; + } + + var somme = 0; + for (var i = 0; i < 10; i++) + { + var c = isbn[i]; + int valeur; + if (char.IsDigit(c)) + { + valeur = c - '0'; + } + else if (c == 'X' && i == 9) + { + // Le X n'est autorisé qu'en position de clé. + valeur = 10; + } + else + { + return false; + } + + somme += valeur * (10 - i); + } + + return somme % 11 == 0; + } + + /// Vrai si (déjà normalisé) est un ISBN-13 / EAN-13 valide, clé comprise. + public static bool EstIsbn13Valide(string? isbn) + { + if (isbn is not { Length: 13 }) + { + return false; + } + + var somme = 0; + for (var i = 0; i < 13; i++) + { + if (!char.IsDigit(isbn[i])) + { + return false; + } + + somme += (isbn[i] - '0') * (i % 2 == 0 ? 1 : 3); + } + + return somme % 10 == 0; + } + + /// Vrai si l'ISBN normalisé est valide sous l'une ou l'autre forme. + public static bool EstValide(string? isbn) => EstIsbn10Valide(isbn) || EstIsbn13Valide(isbn); + + /// + /// Convertit un ISBN-13 en ISBN-10. + /// + /// + /// Uniquement pour le préfixe 978 : les ISBN-13 en 979 n'ont aucun équivalent + /// ISBN-10 et ne doivent pas être « convertis ». Le calcul suit CLAUDE.md : retirer 978, + /// garder les 9 chiffres, recalculer la clé (somme pondérée 10→2, modulo 11, X si 10). + /// + /// true et l'ISBN-10 en sortie, ou false si la conversion est impossible. + public static bool TryConvertirEnIsbn10(string? isbn13, out string? isbn10) + { + isbn10 = null; + + if (!EstIsbn13Valide(isbn13) || !isbn13!.StartsWith("978", StringComparison.Ordinal)) + { + return false; + } + + // Les 9 chiffres significatifs : on retire le préfixe 978 et la clé EAN-13. + var corps = isbn13.Substring(3, 9); + + var somme = 0; + for (var i = 0; i < 9; i++) + { + somme += (corps[i] - '0') * (10 - i); + } + + var cle = (11 - (somme % 11)) % 11; + isbn10 = corps + (cle == 10 ? "X" : cle.ToString()); + return true; + } + + /// + /// URL de la couverture chez OpenLibrary, à utiliser quelle que soit la source + /// des métadonnées : le Dublin Core de la BnF n'en fournit aucune. + /// + /// + /// Le ?default=false est indispensable : sans lui OpenLibrary renvoie une image + /// placeholder au lieu d'un 404, et on afficherait des couvertures vides sans le savoir. + /// + public static string UrlCouverture(string isbn) => + $"https://covers.openlibrary.org/b/isbn/{isbn}-L.jpg?default=false"; +} diff --git a/MaBibli.Tests/BnfSruParserTests.cs b/MaBibli.Tests/BnfSruParserTests.cs new file mode 100644 index 0000000..36ca60b --- /dev/null +++ b/MaBibli.Tests/BnfSruParserTests.cs @@ -0,0 +1,84 @@ +using MaBibli.Api.Services.Isbn; +using MaBibli.Shared.Dtos; + +namespace MaBibli.Tests; + +/// +/// Parsing de réponses SRU réelles de la BnF (schéma dublincore). +/// +public class BnfSruParserTests +{ + [Theory] + // Le piège bloquant, mesuré : ces trois ISBN-13 donnent 0 notice, leur ISBN-10 en donne. + [InlineData("bnf-9782070612758.xml", 1)] // Le Petit Prince (2007) : trouvé en ISBN-13 + [InlineData("bnf-9782253004226.xml", 0)] // Germinal en ISBN-13 : rien + [InlineData("bnf-2253004227.xml", 3)] // Germinal en ISBN-10 : 3 rééditions + [InlineData("bnf-9782080704092.xml", 0)] // Le Horla en ISBN-13 : rien + [InlineData("bnf-2080704095.xml", 1)] // Le Horla en ISBN-10 : 1 notice + [InlineData("bnf-9780262033848.xml", 0)] // Livre étranger : absent du dépôt légal français + [InlineData("bnf-0262033844.xml", 0)] // …sous les deux formes d'ISBN + public void NombreDeNotices_correspond_aux_mesures(string fixture, int attendu) + => Assert.Equal(attendu, BnfSruParser.LireNombreDeNotices(Fixture.Lire(fixture))); + + [Fact] + public void Petit_Prince_est_parse_et_nettoye() + { + var candidats = BnfSruParser.Parser( + Fixture.Lire("bnf-9782070612758.xml"), "9782070612758", "https://couverture"); + + var c = Assert.Single(candidats); + Assert.Equal("Le petit prince", c.Titre); + Assert.Equal("Antoine de Saint-Exupéry", c.Auteur); + Assert.Equal("Gallimard", c.Editeur); + Assert.Equal("2007", c.Annee); + Assert.Equal("fre", c.Langue); + Assert.Equal(SourceMetadonnees.Bnf, c.Source); + Assert.Equal("9782070612758", c.IsbnInterroge); + Assert.Equal("https://couverture", c.CoverUrl); + Assert.StartsWith("ark:/12148/", c.IdentifiantSource); + } + + [Fact] + public void Germinal_renvoie_les_trois_reeditions_distinctes() + { + // Décision actée : on ne choisit pas à la place de l'utilisateur, on remonte tout. + var candidats = BnfSruParser.Parser( + Fixture.Lire("bnf-2253004227.xml"), "2253004227", null); + + Assert.Equal(3, candidats.Count); + Assert.All(candidats, c => Assert.Equal("Germinal", c.Titre)); + Assert.All(candidats, c => Assert.Equal("Émile Zola", c.Auteur)); + + Assert.Equal( + ["le Livre de poche", "Librairie générale française", "Librairie générale française"], + candidats.Select(c => c.Editeur)); + + Assert.Equal(["1979", "2000", "1983"], candidats.Select(c => c.Annee)); + + // Les notices doivent rester discernables : sans ark distinct l'utilisateur ne peut pas trancher. + Assert.Equal(3, candidats.Select(c => c.IdentifiantSource).Distinct().Count()); + } + + [Fact] + public void Le_Horla_est_parse_depuis_lisbn10() + { + var candidats = BnfSruParser.Parser( + Fixture.Lire("bnf-2080704095.xml"), "2080704095", null); + + var c = Assert.Single(candidats); + // La BnF écrit « Le horla » : le nettoyage ne doit pas « corriger » la casse de la source. + Assert.Equal("Le horla", c.Titre); + Assert.Equal("Guy de Maupassant", c.Auteur); + Assert.Equal("2080704095", c.IsbnInterroge); + Assert.DoesNotContain("(", c.Editeur ?? string.Empty); + } + + [Fact] + public void Reponse_sans_notice_donne_une_liste_vide_et_pas_une_exception() + { + var candidats = BnfSruParser.Parser( + Fixture.Lire("bnf-9780262033848.xml"), "9780262033848", null); + + Assert.Empty(candidats); + } +} diff --git a/MaBibli.Tests/Fixture.cs b/MaBibli.Tests/Fixture.cs new file mode 100644 index 0000000..73c298f --- /dev/null +++ b/MaBibli.Tests/Fixture.cs @@ -0,0 +1,15 @@ +namespace MaBibli.Tests; + +/// +/// Accès aux réponses réelles des API, enregistrées le 2026-08-17 dans Fixtures/. +/// Les tests ne touchent jamais le réseau. +/// +internal static class Fixture +{ + public static string Lire(string nomFichier) + { + var chemin = Path.Combine(AppContext.BaseDirectory, "Fixtures", nomFichier); + Assert.True(File.Exists(chemin), $"Fixture manquante : {chemin}"); + return File.ReadAllText(chemin); + } +} diff --git a/MaBibli.Tests/Fixtures/bnf-0262033844.xml b/MaBibli.Tests/Fixtures/bnf-0262033844.xml new file mode 100644 index 0000000..2971bf1 --- /dev/null +++ b/MaBibli.Tests/Fixtures/bnf-0262033844.xml @@ -0,0 +1 @@ +1.21.2bib.isbn all "0262033844"0 \ No newline at end of file diff --git a/MaBibli.Tests/Fixtures/bnf-2080704095.xml b/MaBibli.Tests/Fixtures/bnf-2080704095.xml new file mode 100644 index 0000000..c4570f2 --- /dev/null +++ b/MaBibli.Tests/Fixtures/bnf-2080704095.xml @@ -0,0 +1,24 @@ + +1.2 + +1.2 +bib.isbn all "2080704095" + +1 + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb372712912 Le horla / Maupassant ; établissement du texte, introd., bibliogr. et notes par Antonia Fonyi,... ; chronologie par Pierre Cogny Maupassant, Guy de (1850-1893). Auteur du texte Fonyi, Antonia. Éditeur scientifique Cogny, Pierre (1916-1988). Fonction indéterminée Flammarion (Paris) 1984 Collection : GF ISBN 2080704095 1 vol. (254 p.) ; 18 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb372712912 +1 + +19970602 +20160128 +6.947803 + + + + diff --git a/MaBibli.Tests/Fixtures/bnf-2253004227.xml b/MaBibli.Tests/Fixtures/bnf-2253004227.xml new file mode 100644 index 0000000..d23de04 --- /dev/null +++ b/MaBibli.Tests/Fixtures/bnf-2253004227.xml @@ -0,0 +1,52 @@ + +1.2 + +1.2 +bib.isbn all "2253004227" + +3 + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb34676750b Germinal / Émile Zola ; préface d'Armand Lanoux Zola, Émile (1840-1902). Auteur du texte Lanoux, Armand (1913-1983). Préfacier le Livre de poche (Paris) 1979 Collection : Le Livre de poche ; 145 ISBN 2253004227 503 p. : couv. ill. ; 17 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb34676750b +1 + +19860717 +20160128 +6.554732 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb371181885 Germinal / Émile Zola ; préf., dossier et notes par Colette Becker Zola, Émile (1840-1902). Auteur du texte Becker, Colette (1932-....). Éditeur scientifique Librairie générale française (Paris) 2000 Collection : Classiques de poche ISBN 2253004227 605 p. : couv. ill. en coul. ; 18 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb371181885 +2 + +20000823 +20160128 +6.5542808 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb34728238q Germinal / Émile Zola ; préface de Jacques Duquesne ; commentaires et notes d'Auguste Dezalay Zola, Émile (1840-1902). Auteur du texte Duquesne, Jacques (1930-2023). Préfacier Dezalay, Auguste (1932-2024). Éditeur scientifique Librairie générale française (Paris) 1983 Collection : Le Livre de poche ; 145 ISBN 2253004227 538 p. : couv. ill. en coul. ; 17 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb34728238q +3 + +19860717 +20160128 +6.5534883 + + + + diff --git a/MaBibli.Tests/Fixtures/bnf-9780262033848.xml b/MaBibli.Tests/Fixtures/bnf-9780262033848.xml new file mode 100644 index 0000000..c728b42 --- /dev/null +++ b/MaBibli.Tests/Fixtures/bnf-9780262033848.xml @@ -0,0 +1 @@ +1.21.2bib.isbn all "9780262033848"0 \ No newline at end of file diff --git a/MaBibli.Tests/Fixtures/bnf-9782070612758.xml b/MaBibli.Tests/Fixtures/bnf-9782070612758.xml new file mode 100644 index 0000000..1a2815c --- /dev/null +++ b/MaBibli.Tests/Fixtures/bnf-9782070612758.xml @@ -0,0 +1,24 @@ + +1.2 + +1.2 +bib.isbn all "9782070612758" + +1 + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb41023439w Le petit prince / Antoine de Saint-Exupéry ; avec des aquarelles de l'auteur Saint-Exupéry, Antoine de (1900-1944). Auteur du texte Gallimard (Paris) 2007 Collection : Folio junior ; 100 ISBN 9782070612758 Code à barres commercial : EAN 9782070612758 1 vol. (113 p.) : ill., couv. ill. en coul. ; 18 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb41023439w +1 + +20070503 +20160129 +6.440692 + + + + diff --git a/MaBibli.Tests/Fixtures/bnf-9782080704092.xml b/MaBibli.Tests/Fixtures/bnf-9782080704092.xml new file mode 100644 index 0000000..33583a3 --- /dev/null +++ b/MaBibli.Tests/Fixtures/bnf-9782080704092.xml @@ -0,0 +1 @@ +1.21.2bib.isbn all "9782080704092"0 \ No newline at end of file diff --git a/MaBibli.Tests/Fixtures/bnf-9782253004226.xml b/MaBibli.Tests/Fixtures/bnf-9782253004226.xml new file mode 100644 index 0000000..8185d62 --- /dev/null +++ b/MaBibli.Tests/Fixtures/bnf-9782253004226.xml @@ -0,0 +1 @@ +1.21.2bib.isbn all "9782253004226"0 \ No newline at end of file diff --git a/MaBibli.Tests/Fixtures/ol-author-OL1004780A.json b/MaBibli.Tests/Fixtures/ol-author-OL1004780A.json new file mode 100644 index 0000000..dbf84b3 --- /dev/null +++ b/MaBibli.Tests/Fixtures/ol-author-OL1004780A.json @@ -0,0 +1 @@ +{"alternate_names": ["Thomas Cormen", "Cormen"], "type": {"key": "/type/author"}, "name": "Thomas H. Cormen", "key": "/authors/OL1004780A", "personal_name": "Thomas H. Cormen", "remote_ids": {"wikidata": "Q2524992"}, "latest_revision": 5, "revision": 5, "created": {"type": "/type/datetime", "value": "2008-04-01T03:28:50.625462"}, "last_modified": {"type": "/type/datetime", "value": "2025-08-06T17:34:42.793123"}} \ No newline at end of file diff --git a/MaBibli.Tests/Fixtures/ol-author-OL2633511A.json b/MaBibli.Tests/Fixtures/ol-author-OL2633511A.json new file mode 100644 index 0000000..7681f22 --- /dev/null +++ b/MaBibli.Tests/Fixtures/ol-author-OL2633511A.json @@ -0,0 +1 @@ +{"personal_name": "Clifford Stein", "birth_date": "1965", "type": {"key": "/type/author"}, "source_records": ["bwb:9780262046305", "amazon:6053556491", "promise:bwb_daily_pallets_2021-07-13", "bwb:9781495319280"], "name": "Clifford Stein", "alternate_names": ["Tahsin Oner, Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, Clifford Stein, Urfat Nuriyev, Efendi Nasiboglu", "Clifford Stein, Robert L. Drysdale, Kenneth Bogart", "by by Thomas H. Cormen Charles E. Leiserson Ronald L. Rivest Clifford Stein Thomas H. Cormen Clara Lee Erica Lin"], "key": "/authors/OL2633511A", "remote_ids": {"viaf": "23967971"}, "latest_revision": 4, "revision": 4, "created": {"type": "/type/datetime", "value": "2008-04-29T13:35:46.876380"}, "last_modified": {"type": "/type/datetime", "value": "2023-10-11T14:36:58.897915"}} \ No newline at end of file diff --git a/MaBibli.Tests/Fixtures/ol-author-OL2682480A.json b/MaBibli.Tests/Fixtures/ol-author-OL2682480A.json new file mode 100644 index 0000000..88a8a8c --- /dev/null +++ b/MaBibli.Tests/Fixtures/ol-author-OL2682480A.json @@ -0,0 +1 @@ +{"alternate_names": ["Ronald L Rivest", "Rivest"], "type": {"key": "/type/author"}, "key": "/authors/OL2682480A", "name": "Ronald L. Rivest", "latest_revision": 3, "revision": 3, "created": {"type": "/type/datetime", "value": "2008-04-29T13:35:46.876380"}, "last_modified": {"type": "/type/datetime", "value": "2023-09-26T19:55:49.027294"}} \ No newline at end of file diff --git a/MaBibli.Tests/Fixtures/ol-author-OL31901A.json b/MaBibli.Tests/Fixtures/ol-author-OL31901A.json new file mode 100644 index 0000000..c9cfc11 --- /dev/null +++ b/MaBibli.Tests/Fixtures/ol-author-OL31901A.json @@ -0,0 +1 @@ +{"photos": [6338378], "date": "1900.6~1944.7.", "key": "/authors/OL31901A", "links": [{"title": "Antoine de Saint-Exup\u00e9ry \u2013 Life and Works", "url": "https://love-books-review.com/reviews-by-author/antoine-de-saint-exupery/", "type": {"key": "/type/link"}}, {"title": "users.uoa.gr/~nektar/arts/tributes/antoine_de_saint-exupery_le_petit_prince/index.htm", "url": "http://users.uoa.gr/~nektar/arts/tributes/antoine_de_saint-exupery_le_petit_prince/index.htm", "type": {"key": "/type/link"}}], "personal_name": "Antoine de Saint-Exup\u00e9ry", "birth_date": "29 June 1900", "photograph": "/static/images/book.trans.gif", "title": "Saint-Exupery, Antoine de", "alternate_names": ["A de Saint-Exupery", "A. de Saint-Exupery", "A. Saint-Exupery", "Antione de Saint-Exupery", "Anto Saint Exupery", "Antoine de Saint Exup\u00e9ry", "Antoine de Saint-Exupery", "Antoine de Saint-exup\u00e9ry", "Antoine de St-Exupery", "Antoine Exupery", "Antoine Jean-Baptiste Marie Roger de Saint Exup\u00e9ry", "Antoine Saint-Exup\u00e9ry", "Antoine De Saint-Exup\u00e9ry", "Shengaikesupeili", "Antoine de Antoine de Saint-Exup\u00e9ry", "Antoine De Saint-Exupery", "L'auteur Antoine de Saint-Exup\u00e9ry (Ecrivain)", "Antoine De Saint Exup\u00e9ry", "Antoine de SAINT-EXUPERY", "de Saint-Exup", "Antoine Antoine de Saint-Exup\u00e9ry", "Antoine De Saint Exupery", "Antoine de Saint Exupery", "ANTOINE DE SAINT-EXUPERY", "Antoine De Saint-exupery", "Antoine de Saint-Ex\u00fapery", "Antoine De Saint - Exup\u00e9ry", "ANTOINE DE SAINT-EXUP\u00c9RY", "ANTOINE DE SAINT EXUPERY", "Antoine de Saint- Exup\u00e9ry", "antoine de saint-exupery", "antoine de saint- exupery", "Antoine De Saint - Exupery", "Antoine DE SAINT-EXUP\u00c9RY", "Antoine DE SAINT-EXUPERY", "Antoine De Saint-exup\u00e9ry", "Antoine DE SAINT EXUPERY", "Antoine de Antoine de Saint Exup\u00e9ry", "Antoine de Saint-Exup\u00e9ry de Saint-Exup\u00e9ry", "Saint-Exup\u00e9ry de Antoine", "De Saint Exupery Antoine", "Saint-Exup\u00e9ry Antoine De", "Sheng ai ke su pei li Saint-Exupery, Antoine de", "(fa) Sheng, ai ke su pei li (Saint-Exupery, Antoine de", "De Saint-Exupery,Antoine", "De Saint-Exup\u00e9ry Antoine", "Antoine de Saint-Saint-Exupery", "Antoine Saint Exupery", "Antoint St Exupery", "Anton Saint-Exupery", "Saint-Exupery A.", "Antoi Saint Exupery", "A. St Exupery", "Antonne de Saint - Exupery", "Saint Exupery", "A. De Saint-Exupery", "Antoine Saint Exup\u00e9ry", "DE SAINT-EXUPERY ANT", "Antonie de Saint Exupery", "A. de Saint-Exup\u00e9ry", "SAINT-EXUPERY", "Antoine de Saint-Exupery Estate", "Antoine De Saint-Exupery; Hiroshi Mino", "Antoine de (trans F. A. Shuffrey) Saint-Exupery", "SAINT EXUPERY", "Saint \u00c9xupery", "Antonie De Saint-Exupery", "antoine de saint-exup\u00e9ry", "Antoine De Antoine De Saint-Exup\u00e9ry", "Exupery Antoine de Saint", "ANTONIE DE SAINT-EXUPERY", "Antoi Saint-exupery", "Antoine de Saint-Exup\u00e8ry", "Antoine de (Adaptaci\u00f3n: Sara Torrico) Saint-Exup\u00e9ry", "Antoine Saint-Exupery", "SAINT EXUPERY ANTOINE DE", "Antoine de Saint - Exup\u00e9ry", "ANTOINE DE SAINT - EXUP\u00c9RY", "Saint Exup\u00e9ry", "EXUPERY,SAINT", "Aintoine de Saint-Exup\u00e9ry", "SAINT-EXUPERY,ANTONIE DE", "Antoine de Saint-Exupe\u0301ry", "Antoine St Exupery", "Antoine de de Saint-Exup\u00e9ry", "Antoine De Saint-Exup\u00e9ry De Saint-Exup\u00e9ry", "Antoine De Saint-Exupery 생텍쥐페리", "Antoine De Saint-Exup\u00e9ry,David Wilkinson", "Antoine de Antoine de Saint - Exupery", "Antoine Antoine de Saint Exupery", "Antoine de Sant-Exup\u00e9ry", "Antoine Antoine De Saint-Exup\u00e9ry", "Antoine de saint exupery", "Antone De Saint-Exupery", "Antoine De Sant Exupery", "Antoine de Sint Exupery", "Antoine de Saint- Exupery"], "remote_ids": {"viaf": "93810507", "wikidata": "Q2908", "isni": "0000000120966599", "bookbrainz": "3a359404-4529-4f34-af80-fd3ba1025e81", "musicbrainz": "6b9700da-9eb3-4c1c-881c-01226ed5fc5f", "goodreads": "1020792", "imdb": "nm0756686", "lc_naf": "n80126188", "librarything": "antoinedesaintexuper", "opac_sbn": "CFIV037163"}, "death_date": "31 July 1944", "name": "Antoine de Saint-Exup\u00e9ry", "source_records": ["amazon:8483317508", "amazon:8490740968", "amazon:2215126302", "bwb:9782898023279", "amazon:9563100875", "bwb:9781794080676", "bwb:9798842815371", "amazon:7201077643", "bwb:9781729103715", "bwb:9781983136887", "amazon:6057861558", "amazon:6078252755", "amazon:9876124943", "amazon:2245267346", "bwb:9780241444313", "bwb:9798721172151", "amazon:9974694833", "amazon:9500299119", "amazon:950603317X", "amazon:857406677X", "amazon:0140301844", "promise:bwb_daily_pallets_2022-06-01", "marc:marc_uic/UIC_2022.mrc:2736662:906", "marc:marc_uic/UIC_2022.mrc:73238601:737", "promise:bwb_daily_pallets_2022-09-12", "bwb:9781689353229", "promise:bwb_daily_pallets_2021-08-31", "bwb:9798676345570", "promise:bwb_daily_pallets_2021-05-14", "amazon:2072431271", "promise:bwb_daily_pallets_2021-02-17", "bwb:9781983061547", "bwb:9798671338485", "amazon:7547722903", "amazon:9500752115", "promise:bwb_daily_pallets_2021-04-21", "ia:yehangrendedadiv0000shen", "ia:xiaowangzi0000fash_b8z8", "promise:bwb_daily_pallets_2022-03-17", "bwb:9781071338209", "promise:bwb_daily_pallets_2022-09-14", "amazon:3150078474", "promise:bwb_daily_pallets_2022-08-13:O8-AAN-472", "promise:bwb_daily_pallets_2022-05-25:W7-BTB-546", "amazon:8899997047", "amazon:9681502566", "amazon:1530305020", "amazon:1909621552", "amazon:605468843X", "amazon:9753102429", "amazon:9877475154", "amazon:0789335069", "amazon:4846004430", "promise:bwb_daily_pallets_2021-03-31", "promise:bwb_daily_pallets_2020-09-09", "amazon:9706271821", "bwb:9780008639952", "bwb:9798676350666", "bwb:9798365870567", "bwb:9798514393169", "bwb:9798755078900", "bwb:9798679927766", "bwb:9798671338454", "bwb:9798619419764", "promise:bwb_daily_pallets_2022-09-12:W7-CND-035", "promise:bwb_daily_pallets_2022-08-13:O8-CBN-971", "promise:bwb_daily_pallets_2022-07-28:W7-CES-909", "promise:bwb_daily_pallets_2022-07-26:KR-361-028", "promise:bwb_daily_pallets_2022-03-17:W7-BHC-291", "amazon:8482885812", "amazon:8467742895", "amazon:8490745692", "amazon:9865671298", "amazon:9807875137", "amazon:8476697430", "amazon:8419190055", "amazon:8494510517", "amazon:8893677962", "amazon:8893674726", "amazon:8868215950", "amazon:8434210622", "amazon:6079723921", "promise:bwb_daily_pallets_2023-04-21:P8-DCA-314", "promise:bwb_daily_pallets_2023-04-10:W8-BSW-092", "bwb:9798689140032", "bwb:9798451423172", "promise:bwb_daily_pallets_2023-12-08:O9-CDZ-609", "bwb:9788419275479", "promise:bwb_daily_pallets_2024-01-catchup:KS-436-194", "promise:bwb_daily_pallets_2024-07-09:W9-BMQ-991", "bwb:9781511430197", "bwb:9781542871303", "bwb:9781540623430", "bwb:9781534656451", "bwb:9781494993528", "bwb:9781986248648", "bwb:9781966482062", "ia:airmansodyssey0000anto", "ia:antoinedesaintex0000unkn", "ia:bwb_KU-356-293", "ia:bwb_O8-CGE-681", "ia:bwb_P9-BJM-381", "ia:bwb_S0-AEF-639", "ia:bwb_S0-AWD-750", "ia:bwb_S0-BKY-114", "ia:bwb_W9-CXK-237", "ia:pilotedeguerre0000anto_g8c1"], "bio": "Antoine de Saint-Exup\u00e9ry (29 June 1900\u201431 July 1944) was a French writer and aviator. He is best remembered for his novella *The Little Prince (Le Petit Prince)*, and for his books about aviation adventures, including Night Flight and Wind, Sand and Stars.\r\n\r\nHe was a successful commercial pilot before World War II, joining the Arm\u00e9e de l'Air (French Air Force) on the outbreak of war, flying reconnaissance missions until the armistice with Germany. Following a spell of writing in the United States, he joined the Free French Forces. He disappeared on a reconnaissance flight over the Mediterranean in July 1944.\r\n\r\n([Source][1])\r\n\r\n\r\n [1]: http://en.wikipedia.org/wiki/Antoine_de_Saint-Exup\u00e9ry", "type": {"key": "/type/author"}, "latest_revision": 32, "revision": 32, "created": {"type": "/type/datetime", "value": "2008-04-01T03:28:50.625462"}, "last_modified": {"type": "/type/datetime", "value": "2026-08-10T11:55:54.966946"}} \ No newline at end of file diff --git a/MaBibli.Tests/Fixtures/ol-author-OL32772A.json b/MaBibli.Tests/Fixtures/ol-author-OL32772A.json new file mode 100644 index 0000000..4bec24a --- /dev/null +++ b/MaBibli.Tests/Fixtures/ol-author-OL32772A.json @@ -0,0 +1 @@ +{"type": {"key": "/type/author"}, "authors": [{"type": {"key": "/type/author_role"}, "author": "/authors/OL1035797A"}], "lc_classifications": ["BS511.2 .M38 1983"], "source_records": ["marc:marc_loc_2016/BooksAll.2016.part14.utf8:44902322:661", "ia:largent0000zola_x5y8", "amazon:1522994696", "amazon:1507893590", "amazon:1374885126", "amazon:1546575251", "amazon:2011935814", "bwb:9798725651614", "amazon:2211075487", "bwb:9798744611989", "amazon:2080721984", "amazon:2277119024", "amazon:2491865025", "amazon:0343116146", "amazon:034410950X", "amazon:1533289921", "amazon:1539051706", "amazon:0342133217", "ia:documentslittera00zola", "amazon:1783100281", "bwb:9798479747168", "bwb:9780342659180", "bwb:9781513133201", "bwb:9798452511731", "bwb:9798647929167", "bwb:9798493288180", "amazon:9754340390", "amazon:1014154979", "amazon:9759099004", "amazon:9759099268", "amazon:6059115721", "amazon:9755708855", "amazon:9759099144", "marc:marc_uic/UIC_2022.mrc:6162700:1247", "bwb:9780342133222", "bwb:9782812413773", "bwb:9780996655125", "amazon:3368406892", "bwb:9781520427072", "bwb:9781549856327", "bwb:9781973572022", "bwb:9781980998709", "bwb:9781549856549", "bwb:9781973258513", "bwb:9781795490092", "bwb:9798424798467", "bwb:9780274794744", "promise:bwb_daily_pallets_2021-03-26", "promise:bwb_daily_pallets_2021-02-15", "promise:bwb_daily_pallets_2021-02-02", "promise:bwb_daily_pallets_2020-10-07", "marc:harvard_bibliographic_metadata/ab.bib.13.20150123.full.mrc:406303964:833", "promise:bwb_daily_pallets_2021-01-13", "bwb:9781016886864", "bwb:9798480105315", "amazon:8416948380", "bwb:9781014405814", "promise:bwb_daily_pallets_2024-05-24:P9-CEY-153", "bwb:9781530316380", "ia:bwb_KU-705-801", "ia:bwb_W1-CAE-947", "ia:germinal0000mile_j7b7"], "alternate_names": ["\u00c9mile \u00c9douard Charles Antoine Zola", "\u042d\u043c\u0438\u043b\u044c \u0417\u043e\u043b\u044f", "Emile Zola"], "personal_name": "Emile Zola", "death_date": "28 September 1902", "name": "\u00c9mile Zola", "birth_date": "2 April 1840", "subjects": ["Romanian Poets", "Travel", "Description and travel", "Biography"], "remote_ids": {"viaf": "32004502", "wikidata": "Q504", "isni": "0000000120958660", "bookbrainz": "feff739d-3d0e-43e8-b520-dd7a63e760bb", "musicbrainz": "591846b4-5f9d-4068-b656-b8182a1fa8b2", "goodreads": "4750", "imdb": "nm0957652", "lc_naf": "n79026785", "librarything": "zolaemile", "librivox": "1233", "project_gutenberg": "528", "opac_sbn": "CFIV006123"}, "bio": "Emile Zola was a French journalist and novelist known for his series of 20 novels known collectively as Les Rougon-Macquart (1871-93). Zola's style was called literary naturalism; his novels were attacked and even banned for their frankness and sordid detail, and caused quite a bit of controversy in their day. The same traits made him a best-selling author and a star of French literature in his day. In 1898 he then further incurred the wrath of French officials when he published the open letter \"J'Accuse,\" in defense of Alfred Dreyfus, an Army officer who had been convicted of treason. Zola was sentenced to prison for libel, fled to England, and was granted amnesty a few months later. He died in Paris from carbon monoxide poisoning -- the victim of a stopped-up chimney -- a few months before Dreyfus was officially exonerated.\r\n[(Source)][1]\r\n\r\n\r\n [1]: http://www.infoplease.com/biography/var/emilezola.html", "works": [{"key": "/works/OL22683476W"}], "key": "/authors/OL32772A", "lccn": ["82048589"], "title": "(fa) (Zola, Emile", "photos": [7322184, 7277175, 7321584], "latest_revision": 36, "revision": 36, "created": {"type": "/type/datetime", "value": "2008-04-01T03:28:50.625462"}, "last_modified": {"type": "/type/datetime", "value": "2025-08-11T03:10:32.196053"}} \ No newline at end of file diff --git a/MaBibli.Tests/Fixtures/ol-author-OL3328609A.json b/MaBibli.Tests/Fixtures/ol-author-OL3328609A.json new file mode 100644 index 0000000..d11d18b --- /dev/null +++ b/MaBibli.Tests/Fixtures/ol-author-OL3328609A.json @@ -0,0 +1 @@ +{"name": "Charles E. Leiserson", "personal_name": "Charles Eric Leiserson", "created": {"type": "/type/datetime", "value": "2008-04-30T09:38:13.731961"}, "alternate_names": ["Charles Eric Leiserson", "Charles E Leiserson"], "last_modified": {"type": "/type/datetime", "value": "2020-09-13T22:29:03.142106"}, "latest_revision": 2, "key": "/authors/OL3328609A", "type": {"key": "/type/author"}, "revision": 2} \ No newline at end of file diff --git a/MaBibli.Tests/Fixtures/ol-isbn-9780262033848.json b/MaBibli.Tests/Fixtures/ol-isbn-9780262033848.json new file mode 100644 index 0000000..8555eec --- /dev/null +++ b/MaBibli.Tests/Fixtures/ol-isbn-9780262033848.json @@ -0,0 +1 @@ +{"publishers": ["The MIT Press"], "covers": [11106524, 11106513, 6959894], "local_id": ["urn:sfpl:31223098370225", "urn:sfpl:31223098370217", "urn:sfpl:31223096357745", "urn:sfpl:31223088622619", "urn:sfpl:31223125654849"], "key": "/books/OL23170657M", "publish_places": ["Cambridge, MA, USA"], "contributions": ["Cormen, Thomas H."], "languages": [{"key": "/languages/eng"}], "pagination": "p. cm.", "source_records": ["marc:marc_loc_updates/v37.i09.records.utf8:9778438:742", "marc:marc_loc_updates/v37.i45.records.utf8:8003233:1035", "marc:marc_loc_updates/v38.i08.records.utf8:25220449:1035", "ia:introductiontoal00corm_105", "ia:introductiontoal00corm_558", "ia:introductiontoal00corm_453", "ia:introductiontoal00corm_532", "ia:introductiontoal00corm_281", "amazon:0262533057", "marc:marc_openlibraries_sanfranciscopubliclibrary/sfpl_chq_2018_12_24_run04.mrc:37376133:3028", "bwb:9780262533058", "marc:marc_loc_2016/BooksAll.2016.part36.utf8:104035279:1035", "bwb:9780262033848", "ia:introductiontoal0000unse_t3a6", "marc:marc_columbia/Columbia-extract-20221130-022.mrc:155289756:5406", "marc:marc_columbia/Columbia-extract-20221130-015.mrc:131921602:2839", "marc:harvard_bibliographic_metadata/ab.bib.12.20150123.full.mrc:192775671:2581", "idb:9780262033848", "idb:9780262533058"], "title": "Introduction to Algorithms", "notes": "Includes bibliographical references and index.", "identifiers": {"librarything": ["9820219"], "goodreads": ["6752187", "7160858"]}, "edition_name": "Third Edition", "subjects": ["Computer programming", "Computer algorithms"], "publish_date": "2009", "publish_country": "mau", "by_statement": "Thomas H. Cormen ... [et al.].", "works": [{"key": "/works/OL4781294W"}], "type": {"key": "/type/edition"}, "ocaid": "introductiontoal00corm_453", "isbn_13": ["9780262033848", "9780262533058"], "lccn": ["2009008593"], "classifications": {}, "dewey_decimal_class": ["005.1"], "lc_classifications": ["QA76.6 .I5858 2009", "QA76.6.I5858 2009", "QA76.6 .C662 2009", "QA76.6 .C662 2009eb"], "oclc_numbers": ["676697295", "311310321"], "number_of_pages": 1292, "latest_revision": 28, "revision": 28, "created": {"type": "/type/datetime", "value": "2009-05-14T08:11:40.465407"}, "last_modified": {"type": "/type/datetime", "value": "2023-12-19T21:37:40.645363"}} \ No newline at end of file diff --git a/MaBibli.Tests/Fixtures/ol-isbn-9782070612758.json b/MaBibli.Tests/Fixtures/ol-isbn-9782070612758.json new file mode 100644 index 0000000..fce8d23 --- /dev/null +++ b/MaBibli.Tests/Fixtures/ol-isbn-9782070612758.json @@ -0,0 +1 @@ +{"publishers": ["Editions Gallimard"], "number_of_pages": 120, "weight": "5.6 ounces", "isbn_10": ["2070612759"], "covers": [2137711], "physical_format": "Paperback", "key": "/books/OL9567312M", "authors": [{"key": "/authors/OL31901A"}], "subjects": ["Classic fiction", "Fiction", "Children's Books/All Ages", "Literature: Classics", "Fairy Tales & Folklore - Anthologies", "Classics"], "isbn_13": ["9782070612758"], "classifications": {}, "source_records": ["marc:marc_openlibraries_sanfranciscopubliclibrary/sfpl_chq_2018_12_24_run05.mrc:292011609:2702"], "title": "Le Petit Prince", "identifiers": {"goodreads": ["832605"]}, "languages": [{"key": "/languages/fre"}], "local_id": ["urn:sfpl:31223118874636", "urn:sfpl:31223118874644"], "publish_date": "March 2007", "works": [{"key": "/works/OL10263W"}], "type": {"key": "/type/edition"}, "physical_dimensions": "6.9 x 4.9 x 0.3 inches", "latest_revision": 8, "revision": 8, "created": {"type": "/type/datetime", "value": "2008-04-30T09:38:13.731961"}, "last_modified": {"type": "/type/datetime", "value": "2023-01-18T09:47:14.521060"}} \ No newline at end of file diff --git a/MaBibli.Tests/Fixtures/ol-isbn-9782253004226.json b/MaBibli.Tests/Fixtures/ol-isbn-9782253004226.json new file mode 100644 index 0000000..122b72c --- /dev/null +++ b/MaBibli.Tests/Fixtures/ol-isbn-9782253004226.json @@ -0,0 +1 @@ +{"publishers": ["[Librairie G\u00e9n\u00e9rale Fran\u00e7aise]"], "number_of_pages": 503, "isbn_10": ["2253004227"], "series": ["Le livre de poche -- 145", "Les Rougons-Macquart -- 13"], "key": "/books/OL22818649M", "authors": [{"key": "/authors/OL32772A"}], "publish_places": ["Paris"], "pagination": "503p. ;", "source_records": ["marc:talis_openlibrary_contribution/talis-openlibrary-contribution.mrc:1030036699:704", "marc:marc_openlibraries_sanfranciscopubliclibrary/sfpl_chq_2018_12_24_run02.mrc:61717751:1157", "ia:germinal0000zola_f0g5", "marc:harvard_bibliographic_metadata/ab.bib.00.20150123.full.mrc:781943779:596", "marc:harvard_bibliographic_metadata/20220215_017.bib.mrc:282780577:1098"], "title": "Germinal", "notes": {"type": "/type/text", "value": "This ed. first published by Librairie G\u00e9n\u00e9rale Fran\u00e7aise: 1956. Reissued with a new preface. Originally published: Fasquelle."}, "identifiers": {"librarything": ["22853"], "goodreads": ["956700"]}, "languages": [{"key": "/languages/fre"}], "local_id": ["urn:sfpl:31223073722390"], "publish_date": "1983", "publish_country": "fr ", "by_statement": "pr\u00e9face de Jacques Duquesne.", "works": [{"key": "/works/OL118986W"}], "type": {"key": "/type/edition"}, "covers": [10628937], "ocaid": "germinal0000zola_f0g5", "oclc_numbers": ["7710691"], "latest_revision": 12, "revision": 12, "created": {"type": "/type/datetime", "value": "2009-01-03T22:27:36.844842"}, "last_modified": {"type": "/type/datetime", "value": "2025-09-28T12:59:52.355511"}} \ No newline at end of file diff --git a/MaBibli.Tests/Fixtures/ol-work-OL4781294W.json b/MaBibli.Tests/Fixtures/ol-work-OL4781294W.json new file mode 100644 index 0000000..c8a7f88 --- /dev/null +++ b/MaBibli.Tests/Fixtures/ol-work-OL4781294W.json @@ -0,0 +1 @@ +{"subjects": ["Algorithms", "Computer algorithms", "Computer programming", "open_syllabus_project", "Programming", "Algorithmes", "Programmation (Informatique)", "54.10 theoretical informatics", "Algorithmus", "Informatik", "Theoretische Informatik", "Algorithmentheorie", "COMPUTER PROGRAMS", "PROGRAMMING LANGUAGES", "FILE MAINTENANCE (COMPUTERS)", "SOFTWARE TOOLS", "Long Now Manual for Civilization", "COMPUTERS", "Open Source", "Software Development & Engineering", "Tools", "General", "Algorithmische Programmierung", "Algoritmen", "Datenstruktur", "Datoralgoritmer", "Datastrukturer", "\u041a\u043e\u043c\u043f\u044c\u044e\u0442\u0435\u0440\u044b//\u0410\u043b\u0433\u043e\u0440\u0438\u0442\u043c\u044b \u0438 \u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u044b \u0434\u0430\u043d\u043d\u044b\u0445", "\u041a\u043e\u043c\u043f\u044c\u044e\u0442\u0435\u0440\u044b", "\u0410\u043b\u0433\u043e\u0440\u0438\u0442\u043c\u044b \u0438 \u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u044b \u0434\u0430\u043d\u043d\u044b\u0445", "Algorithms and Data Structures", "Qa76.6 .c662 2009", "005.1", "54.10", "Qa76.6 .i5858 2001", "Electronic digital computers, programming"], "key": "/works/OL4781294W", "title": "Introduction to Algorithms", "authors": [{"author": {"key": "/authors/OL1004780A"}, "type": {"key": "/type/author_role"}}, {"author": {"key": "/authors/OL3328609A"}, "type": {"key": "/type/author_role"}}, {"author": {"key": "/authors/OL2682480A"}, "type": {"key": "/type/author_role"}}, {"author": {"key": "/authors/OL2633511A"}, "type": {"key": "/type/author_role"}}], "type": {"key": "/type/work"}, "covers": [2341462, 149636, 6959894, 53671, 8664679, 151333, 11115497], "description": {"type": "/type/text", "value": "This book provides a comprehensive introduction to the modern study of computer algorithms. It presents many algorithms and covers them in considerable depth, yet makes their design and analysis accessible to all levels of readers. We have tried to keep explanations elementary without sacrificing depth of coverage or mathematical rigor. Each chapter presents an algorithm, a design technique, an application area, or a related topic. Algorithms are described in English and in a \"pseudocode\" designed to be readable by anyone who has done a little programming. The book contains over 260 figrues illustrating how the algorithms work. Since we emphasize efficiency as a design criterion, we include careful analyses of the running times of all our algorithms. The text is intended primarily for use in undergraduate or graduate courses in algorithms or data structures. Because it discusses engineering issues in algorithm design, as well as mathematical aspects, it is equally well suited for self-study by technical professionals. --"}, "latest_revision": 17, "revision": 17, "created": {"type": "/type/datetime", "value": "2009-12-10T07:30:51.510609"}, "last_modified": {"type": "/type/datetime", "value": "2025-05-16T12:11:10.698473"}} \ No newline at end of file diff --git a/MaBibli.Tests/IsbnLookupServiceTests.cs b/MaBibli.Tests/IsbnLookupServiceTests.cs new file mode 100644 index 0000000..8c40359 --- /dev/null +++ b/MaBibli.Tests/IsbnLookupServiceTests.cs @@ -0,0 +1,185 @@ +using MaBibli.Api.Services.Isbn; +using MaBibli.Shared.Dtos; +using Microsoft.Extensions.Logging.Abstractions; + +namespace MaBibli.Tests; + +/// +/// Enchaînement de la cascade BnF (ISBN-13) → BnF (ISBN-10) → OpenLibrary, +/// avec des sources simulées : aucun accès réseau. +/// +public class IsbnLookupServiceTests +{ + private sealed class BnfFake : IBnfClient + { + public required Func> Reponse { get; init; } + + public List IsbnInterroges { get; } = []; + + public string? Avertissement { get; init; } + + public Task<(IReadOnlyList, string?)> RechercherAsync( + string isbn, string? urlCouverture, CancellationToken ct = default) + { + IsbnInterroges.Add(isbn); + return Task.FromResult((Reponse(isbn), Avertissement)); + } + } + + private sealed class OpenLibraryFake : IOpenLibraryClient + { + public required Func> Reponse { get; init; } + + public List IsbnInterroges { get; } = []; + + public Task<(IReadOnlyList, string?)> RechercherAsync( + string isbn, string? urlCouverture, CancellationToken ct = default) + { + IsbnInterroges.Add(isbn); + return Task.FromResult((Reponse(isbn), (string?)null)); + } + } + + private static CandidatLivre Notice(SourceMetadonnees source, string isbn) => + new() { Titre = "Un titre", Source = source, IsbnInterroge = isbn }; + + private static IsbnLookupService Service(IBnfClient bnf, IOpenLibraryClient ol) => + new(bnf, ol, NullLogger.Instance); + + [Fact] + public async Task Sarrete_a_la_BnF_quand_lISBN13_suffit() + { + var bnf = new BnfFake { Reponse = isbn => [Notice(SourceMetadonnees.Bnf, isbn)] }; + var ol = new OpenLibraryFake { Reponse = _ => [] }; + + var r = await Service(bnf, ol).RechercherAsync("978-2-07-061275-8"); + + Assert.NotNull(r); + Assert.Equal("9782070612758", r.IsbnDemande); + Assert.Equal("2070612759", r.Isbn10); + Assert.Equal(["9782070612758"], bnf.IsbnInterroges); + Assert.Empty(ol.IsbnInterroges); + Assert.Single(r.Candidats); + } + + [Fact] + public async Task Reessaie_la_BnF_avec_lISBN10_converti() + { + // Le cas Germinal : rien en ISBN-13, tout en ISBN-10. + var bnf = new BnfFake + { + Reponse = isbn => isbn == "2253004227" + ? [Notice(SourceMetadonnees.Bnf, isbn), Notice(SourceMetadonnees.Bnf, isbn)] + : [], + }; + var ol = new OpenLibraryFake { Reponse = _ => [] }; + + var r = await Service(bnf, ol).RechercherAsync("9782253004226"); + + Assert.Equal(["9782253004226", "2253004227"], bnf.IsbnInterroges); + Assert.Empty(ol.IsbnInterroges); + Assert.Equal(2, r!.Candidats.Count); + Assert.All(r.Candidats, c => Assert.Equal("2253004227", c.IsbnInterroge)); + } + + [Fact] + public async Task Bascule_sur_OpenLibrary_quand_la_BnF_ignore_les_deux_formes() + { + var bnf = new BnfFake { Reponse = _ => [] }; + var ol = new OpenLibraryFake + { + Reponse = isbn => [Notice(SourceMetadonnees.OpenLibrary, isbn)], + }; + + var r = await Service(bnf, ol).RechercherAsync("9780262033848"); + + Assert.Equal(["9780262033848", "0262033844"], bnf.IsbnInterroges); + // OpenLibrary est interrogée avec l'ISBN-13, la forme qu'elle indexe. + Assert.Equal(["9780262033848"], ol.IsbnInterroges); + Assert.Equal(SourceMetadonnees.OpenLibrary, Assert.Single(r!.Candidats).Source); + } + + [Fact] + public async Task Une_BnF_en_panne_ne_fait_pas_echouer_la_cascade() + { + var bnf = new BnfFake { Reponse = _ => [], Avertissement = "BnF injoignable" }; + var ol = new OpenLibraryFake { Reponse = isbn => [Notice(SourceMetadonnees.OpenLibrary, isbn)] }; + + var r = await Service(bnf, ol).RechercherAsync("9782253004226"); + + Assert.Single(r!.Candidats); + Assert.Equal(2, r.Avertissements.Count); // une par tentative BnF + } + + [Fact] + public async Task Aucune_source_ne_trouve_rien() + { + var bnf = new BnfFake { Reponse = _ => [] }; + var ol = new OpenLibraryFake { Reponse = _ => [] }; + + var r = await Service(bnf, ol).RechercherAsync("9782253004226"); + + Assert.NotNull(r); + Assert.Empty(r.Candidats); + Assert.Empty(r.Avertissements); + } + + [Fact] + public async Task ISBN_en_979_ninterroge_la_BnF_quune_seule_fois() + { + var bnf = new BnfFake { Reponse = _ => [] }; + var ol = new OpenLibraryFake { Reponse = _ => [] }; + + var r = await Service(bnf, ol).RechercherAsync("9791023507027"); + + Assert.Null(r!.Isbn10); + Assert.Equal(["9791023507027"], bnf.IsbnInterroges); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("pas-un-isbn")] + [InlineData("9782253004225")] // clé fausse + [InlineData("123")] + public async Task ISBN_invalide_renvoie_null(string entree) + { + var bnf = new BnfFake { Reponse = _ => [] }; + var ol = new OpenLibraryFake { Reponse = _ => [] }; + + Assert.Null(await Service(bnf, ol).RechercherAsync(entree)); + Assert.Empty(bnf.IsbnInterroges); + Assert.Empty(ol.IsbnInterroges); + } + + [Fact] + public async Task La_couverture_OpenLibrary_est_transmise_meme_pour_une_notice_BnF() + { + string? couvertureVue = null; + var bnf = new BnfFake { Reponse = _ => [] }; + var ol = new OpenLibraryFake { Reponse = _ => [] }; + + // On capture l'URL passée à la BnF via un client dédié. + var bnfCapture = new CaptureCouverture(); + var r = await Service(bnfCapture, ol).RechercherAsync("9782253004226"); + couvertureVue = bnfCapture.DerniereCouverture; + + Assert.NotNull(r); + Assert.Equal( + "https://covers.openlibrary.org/b/isbn/9782253004226-L.jpg?default=false", + couvertureVue); + Assert.Empty(bnf.IsbnInterroges); + } + + private sealed class CaptureCouverture : IBnfClient + { + public string? DerniereCouverture { get; private set; } + + public Task<(IReadOnlyList, string?)> RechercherAsync( + string isbn, string? urlCouverture, CancellationToken ct = default) + { + DerniereCouverture = urlCouverture; + return Task.FromResult(((IReadOnlyList)[], (string?)null)); + } + } +} diff --git a/MaBibli.Tests/IsbnUtilsTests.cs b/MaBibli.Tests/IsbnUtilsTests.cs new file mode 100644 index 0000000..90e373f --- /dev/null +++ b/MaBibli.Tests/IsbnUtilsTests.cs @@ -0,0 +1,93 @@ +using MaBibli.Shared.Isbn; + +namespace MaBibli.Tests; + +public class IsbnUtilsTests +{ + [Theory] + [InlineData("978-2-253-00422-6", "9782253004226")] + [InlineData(" 9782253004226 ", "9782253004226")] + [InlineData("2-08-070409-5", "2080704095")] + [InlineData("080442957x", "080442957X")] + public void Normaliser_retire_la_ponctuation(string brut, string attendu) + => Assert.Equal(attendu, IsbnUtils.Normaliser(brut)); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("---")] + public void Normaliser_renvoie_null_si_rien_dexploitable(string? brut) + => Assert.Null(IsbnUtils.Normaliser(brut)); + + // Les quatre ISBN de vérification du projet, plus un cas à clé X. + [Theory] + [InlineData("9782070612758", "2070612759")] // Le Petit Prince + [InlineData("9782253004226", "2253004227")] // Germinal — valeur confirmée par la BnF + [InlineData("9782080704092", "2080704095")] // Le Horla — valeur confirmée par la BnF + [InlineData("9780262033848", "0262033844")] // Introduction to Algorithms + public void Convertir_13_vers_10_donne_la_bonne_cle(string isbn13, string attendu) + { + Assert.True(IsbnUtils.TryConvertirEnIsbn10(isbn13, out var isbn10)); + Assert.Equal(attendu, isbn10); + } + + [Theory] + [InlineData("9780804429573", "080442957X")] // clé = 10 → X + [InlineData("9780000000064", "000000006X")] // second cas à clé X, construit à la main + public void Convertir_13_vers_10_produit_un_X_quand_la_cle_vaut_10(string isbn13, string attendu) + { + Assert.True(IsbnUtils.TryConvertirEnIsbn10(isbn13, out var isbn10)); + Assert.Equal(attendu, isbn10); + Assert.True(IsbnUtils.EstIsbn10Valide(isbn10), "l'ISBN-10 produit doit être valide"); + } + + [Fact] + public void Convertir_refuse_le_prefixe_979() + { + // 979 n'a aucun équivalent ISBN-10 : « convertir » y produirait un identifiant inventé. + const string isbn13 = "9791023507027"; + Assert.True(IsbnUtils.EstIsbn13Valide(isbn13), "l'ISBN-13 de test doit être valide"); + Assert.False(IsbnUtils.TryConvertirEnIsbn10(isbn13, out var isbn10)); + Assert.Null(isbn10); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("2253004227")] // déjà un ISBN-10 + [InlineData("978225300422")] // trop court + [InlineData("9782253004227")] // clé EAN-13 fausse + public void Convertir_refuse_les_entrees_invalides(string? entree) + { + Assert.False(IsbnUtils.TryConvertirEnIsbn10(entree, out var isbn10)); + Assert.Null(isbn10); + } + + [Theory] + [InlineData("2253004227", true)] + [InlineData("2080704095", true)] + [InlineData("080442957X", true)] + [InlineData("2253004228", false)] // clé fausse + [InlineData("22530042X7", false)] // X ailleurs qu'en clé + [InlineData("225300422", false)] // trop court + public void EstIsbn10Valide(string isbn, bool attendu) + => Assert.Equal(attendu, IsbnUtils.EstIsbn10Valide(isbn)); + + [Theory] + [InlineData("9782253004226", true)] + [InlineData("9782070612758", true)] + [InlineData("9782253004225", false)] // clé fausse + [InlineData("978225300422X", false)] // pas de X en ISBN-13 + public void EstIsbn13Valide(string isbn, bool attendu) + => Assert.Equal(attendu, IsbnUtils.EstIsbn13Valide(isbn)); + + [Fact] + public void UrlCouverture_porte_toujours_default_false() + { + // Sans ?default=false, OpenLibrary renvoie un placeholder au lieu d'un 404 : + // on afficherait des couvertures vides sans le savoir. + var url = IsbnUtils.UrlCouverture("9782253004226"); + Assert.Equal("https://covers.openlibrary.org/b/isbn/9782253004226-L.jpg?default=false", url); + } +} diff --git a/MaBibli.Tests/MaBibli.Tests.csproj b/MaBibli.Tests/MaBibli.Tests.csproj new file mode 100644 index 0000000..758bbc5 --- /dev/null +++ b/MaBibli.Tests/MaBibli.Tests.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/MaBibli.Tests/NettoyageIsbdTests.cs b/MaBibli.Tests/NettoyageIsbdTests.cs new file mode 100644 index 0000000..6ce560f --- /dev/null +++ b/MaBibli.Tests/NettoyageIsbdTests.cs @@ -0,0 +1,74 @@ +using MaBibli.Api.Services.Isbn; + +namespace MaBibli.Tests; + +public class NettoyageIsbdTests +{ + [Theory] + [InlineData("Germinal / Émile Zola ; préface d'Armand Lanoux", "Germinal")] + [InlineData("Germinal / Émile Zola ; préf., dossier et notes par Colette Becker", "Germinal")] + [InlineData("Le Horla / Guy de Maupassant", "Le Horla")] + public void Titre_coupe_a_la_mention_de_responsabilite(string brut, string attendu) + => Assert.Equal(attendu, NettoyageIsbd.Titre(brut)); + + [Theory] + [InlineData("Germinal", "Germinal")] + [InlineData("Le Petit Prince", "Le Petit Prince")] + [InlineData("Introduction to Algorithms", "Introduction to Algorithms")] + // Le sous-titre ISBD (« : ») n'est pas de la ponctuation à retirer : on le garde. + [InlineData("Voyage au bout de la nuit : roman", "Voyage au bout de la nuit : roman")] + // Un « / » sans espaces autour appartient au titre, il ne doit pas servir de coupure. + [InlineData("Entrée/sortie", "Entrée/sortie")] + public void Titre_laisse_passer_ce_qui_est_deja_propre(string brut, string attendu) + => Assert.Equal(attendu, NettoyageIsbd.Titre(brut)); + + [Theory] + [InlineData("Zola, Émile (1840-1902). Auteur du texte", "Émile Zola")] + [InlineData("Saint-Exupéry, Antoine de (1900-1944). Auteur du texte", "Antoine de Saint-Exupéry")] + [InlineData("Maupassant, Guy de (1850-1893). Auteur du texte", "Guy de Maupassant")] + [InlineData("Becker, Colette (1932-....). Éditeur scientifique", "Colette Becker")] + [InlineData("Zola, Émile (1840-1902)", "Émile Zola")] + [InlineData("Zola, Émile. Auteur du texte", "Émile Zola")] + public void Auteur_retire_dates_et_role_puis_inverse(string brut, string attendu) + => Assert.Equal(attendu, NettoyageIsbd.Auteur(brut)); + + [Theory] + [InlineData("Émile Zola", "Émile Zola")] + [InlineData("Antoine de Saint-Exupéry", "Antoine de Saint-Exupéry")] + [InlineData("Molière", "Molière")] + public void Auteur_laisse_passer_ce_qui_est_deja_propre(string brut, string attendu) + => Assert.Equal(attendu, NettoyageIsbd.Auteur(brut)); + + [Fact] + public void Auteur_ne_coupe_pas_sur_une_initiale() + { + // « H. » est une initiale, pas un séparateur de rôle : la couper amputerait le prénom. + Assert.Equal("Thomas H. Cormen", NettoyageIsbd.Auteur("Cormen, Thomas H.")); + } + + [Theory] + [InlineData("le Livre de poche (Paris)", "le Livre de poche")] + [InlineData("Librairie générale française (Paris)", "Librairie générale française")] + [InlineData("Gallimard (Paris)", "Gallimard")] + public void Editeur_retire_la_ville_finale(string brut, string attendu) + => Assert.Equal(attendu, NettoyageIsbd.Editeur(brut)); + + [Theory] + [InlineData("Gallimard", "Gallimard")] + [InlineData("The MIT Press", "The MIT Press")] + // Parenthèses en milieu de chaîne : ce n'est pas la ville, on n'y touche pas. + [InlineData("Presses (universitaires) de France", "Presses (universitaires) de France")] + public void Editeur_laisse_passer_ce_qui_est_deja_propre(string brut, string attendu) + => Assert.Equal(attendu, NettoyageIsbd.Editeur(brut)); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Champs_vides_donnent_null(string? brut) + { + Assert.Null(NettoyageIsbd.Titre(brut)); + Assert.Null(NettoyageIsbd.Auteur(brut)); + Assert.Null(NettoyageIsbd.Editeur(brut)); + } +} diff --git a/MaBibli.Tests/OpenLibraryMapperTests.cs b/MaBibli.Tests/OpenLibraryMapperTests.cs new file mode 100644 index 0000000..bd12b08 --- /dev/null +++ b/MaBibli.Tests/OpenLibraryMapperTests.cs @@ -0,0 +1,108 @@ +using System.Text.Json; +using MaBibli.Api.Services.Isbn; +using MaBibli.Shared.Dtos; + +namespace MaBibli.Tests; + +/// +/// Parsing de réponses OpenLibrary réelles. +/// +public class OpenLibraryMapperTests +{ + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + + private static T Charger(string fixture) => + JsonSerializer.Deserialize(Fixture.Lire(fixture), Json)!; + + [Fact] + public void Ledition_ne_donne_quune_reference_dauteur_pas_un_nom() + { + // C'est exactement le bug de BookLogr : sans second appel, l'auteur reste vide. + var edition = Charger("ol-isbn-9782070612758.json"); + + Assert.Equal("Le Petit Prince", edition.Title); + var cle = Assert.Single(OpenLibraryMapper.ClesAuteurs(edition)); + Assert.Equal("/authors/OL31901A", cle); + + // Le nom n'arrive qu'au second appel, sur /authors/{id}.json. + var auteur = Charger("ol-author-OL31901A.json"); + Assert.Equal("Antoine de Saint-Exupéry", OpenLibraryMapper.Nom(auteur)); + } + + [Fact] + public void Petit_Prince_candidat_complet() + { + var edition = Charger("ol-isbn-9782070612758.json"); + var auteur = OpenLibraryMapper.Nom(Charger("ol-author-OL31901A.json"))!; + + var c = OpenLibraryMapper.VersCandidat(edition, [auteur], "9782070612758", "https://couverture")!; + + Assert.Equal("Le Petit Prince", c.Titre); + Assert.Equal("Antoine de Saint-Exupéry", c.Auteur); + Assert.Equal("Editions Gallimard", c.Editeur); + Assert.Equal("March 2007", c.Annee); + Assert.Equal("fre", c.Langue); + Assert.Equal(SourceMetadonnees.OpenLibrary, c.Source); + Assert.Equal("/books/OL9567312M", c.IdentifiantSource); + Assert.Equal("https://couverture", c.CoverUrl); + } + + [Fact] + public void Germinal_chez_OpenLibrary() + { + var edition = Charger("ol-isbn-9782253004226.json"); + var auteur = OpenLibraryMapper.Nom(Charger("ol-author-OL32772A.json"))!; + + var c = OpenLibraryMapper.VersCandidat(edition, [auteur], "9782253004226", null)!; + + Assert.Equal("Germinal", c.Titre); + Assert.Equal("Émile Zola", c.Auteur); + // Les crochets de la notice MARC (« [Librairie Générale Française] ») sont retirés. + Assert.Equal("Librairie Générale Française", c.Editeur); + Assert.Equal("1983", c.Annee); + } + + [Fact] + public void Introduction_to_Algorithms_na_pas_dauteur_sur_ledition_seulement_sur_loeuvre() + { + // Cas réel : le champ `authors` de l'édition est totalement absent. + // Sans repli sur l'œuvre, l'auteur resterait vide alors que le livre en a quatre. + var edition = Charger("ol-isbn-9780262033848.json"); + Assert.Empty(OpenLibraryMapper.ClesAuteurs(edition)); + Assert.Equal("/works/OL4781294W", OpenLibraryMapper.CleOeuvre(edition)); + + var oeuvre = Charger("ol-work-OL4781294W.json"); + var cles = OpenLibraryMapper.ClesAuteurs(oeuvre); + Assert.Equal(4, cles.Count); + + var noms = cles + .Select(c => Charger($"ol-author-{c.Split('/').Last()}.json")) + .Select(a => OpenLibraryMapper.Nom(a)!) + .ToList(); + + var candidat = OpenLibraryMapper.VersCandidat(edition, noms, "9780262033848", null)!; + Assert.Equal("Introduction to Algorithms", candidat.Titre); + Assert.Equal( + "Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, Clifford Stein", + candidat.Auteur); + Assert.Equal("The MIT Press", candidat.Editeur); + Assert.Equal("2009", candidat.Annee); + } + + [Fact] + public void Sans_aucun_auteur_resolvable_on_retombe_sur_by_statement() + { + var edition = Charger("ol-isbn-9780262033848.json"); + + var c = OpenLibraryMapper.VersCandidat(edition, [], "9780262033848", null)!; + + Assert.Equal("Thomas H. Cormen", c.Auteur); + } + + [Fact] + public void Edition_sans_titre_ne_produit_aucun_candidat() + { + var edition = new OpenLibraryEdition { Key = "/books/OL1M" }; + Assert.Null(OpenLibraryMapper.VersCandidat(edition, [], "9782253004226", null)); + } +} diff --git a/MaBibli.sln b/MaBibli.sln index 4ec16ee..dcb8736 100644 --- a/MaBibli.sln +++ b/MaBibli.sln @@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaBibli.Shared", "MaBibli.S EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaBibli.Api", "MaBibli.Api\MaBibli.Api.csproj", "{D9FA3052-4E2D-4281-B098-60EBD7406E22}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaBibli.Tests", "MaBibli.Tests\MaBibli.Tests.csproj", "{50C12C79-8653-4249-AD39-0EC3D2D7BA32}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -55,6 +57,18 @@ Global {D9FA3052-4E2D-4281-B098-60EBD7406E22}.Release|x64.Build.0 = Release|Any CPU {D9FA3052-4E2D-4281-B098-60EBD7406E22}.Release|x86.ActiveCfg = Release|Any CPU {D9FA3052-4E2D-4281-B098-60EBD7406E22}.Release|x86.Build.0 = Release|Any CPU + {50C12C79-8653-4249-AD39-0EC3D2D7BA32}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {50C12C79-8653-4249-AD39-0EC3D2D7BA32}.Debug|Any CPU.Build.0 = Debug|Any CPU + {50C12C79-8653-4249-AD39-0EC3D2D7BA32}.Debug|x64.ActiveCfg = Debug|Any CPU + {50C12C79-8653-4249-AD39-0EC3D2D7BA32}.Debug|x64.Build.0 = Debug|Any CPU + {50C12C79-8653-4249-AD39-0EC3D2D7BA32}.Debug|x86.ActiveCfg = Debug|Any CPU + {50C12C79-8653-4249-AD39-0EC3D2D7BA32}.Debug|x86.Build.0 = Debug|Any CPU + {50C12C79-8653-4249-AD39-0EC3D2D7BA32}.Release|Any CPU.ActiveCfg = Release|Any CPU + {50C12C79-8653-4249-AD39-0EC3D2D7BA32}.Release|Any CPU.Build.0 = Release|Any CPU + {50C12C79-8653-4249-AD39-0EC3D2D7BA32}.Release|x64.ActiveCfg = Release|Any CPU + {50C12C79-8653-4249-AD39-0EC3D2D7BA32}.Release|x64.Build.0 = Release|Any CPU + {50C12C79-8653-4249-AD39-0EC3D2D7BA32}.Release|x86.ActiveCfg = Release|Any CPU + {50C12C79-8653-4249-AD39-0EC3D2D7BA32}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE