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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
using MaBibli.Shared.Dtos;
|
||||
|
||||
namespace MaBibli.Api.Services.Isbn;
|
||||
|
||||
public interface IBnfClient
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 <b>ou</b> si elle est injoignable ;
|
||||
/// dans ce second cas <paramref name="avertissement"/> explique pourquoi.
|
||||
/// </summary>
|
||||
Task<(IReadOnlyList<CandidatLivre> Candidats, string? Avertissement)> RechercherAsync(
|
||||
string isbn, string? urlCouverture, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client de l'API SRU du catalogue général de la BnF (gratuite, sans clé).
|
||||
/// </summary>
|
||||
public sealed class BnfClient(HttpClient http, ILogger<BnfClient> logger) : IBnfClient
|
||||
{
|
||||
/// <summary>Nom du client typé enregistré dans <c>IHttpClientFactory</c>.</summary>
|
||||
public const string NomHttpClient = "bnf";
|
||||
|
||||
public const string UrlBase = "https://catalogue.bnf.fr/";
|
||||
|
||||
public async Task<(IReadOnlyList<CandidatLivre> 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}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using MaBibli.Shared.Dtos;
|
||||
using MaBibli.Shared.Isbn;
|
||||
|
||||
namespace MaBibli.Api.Services.Isbn;
|
||||
|
||||
public interface IIsbnLookupService
|
||||
{
|
||||
/// <summary>
|
||||
/// Cherche toutes les notices correspondant à un ISBN.
|
||||
/// </summary>
|
||||
/// <returns><c>null</c> si l'ISBN fourni n'est pas un ISBN valide.</returns>
|
||||
Task<ResultatLookupIsbn?> RechercherAsync(string isbnBrut, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cascade de lookup ISBN : <b>BnF d'abord, OpenLibrary ensuite</b>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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
|
||||
/// <b>aucune</b> des deux formes d'ISBN.
|
||||
/// </remarks>
|
||||
public sealed class IsbnLookupService(
|
||||
IBnfClient bnf,
|
||||
IOpenLibraryClient openLibrary,
|
||||
ILogger<IsbnLookupService> logger) : IIsbnLookupService
|
||||
{
|
||||
public async Task<ResultatLookupIsbn?> 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<string>();
|
||||
|
||||
// 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<string> avertissements, string? message)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(message))
|
||||
{
|
||||
avertissements.Add(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace MaBibli.Api.Services.Isbn;
|
||||
|
||||
/// <summary>
|
||||
/// Nettoyage de la ponctuation ISBD des champs Dublin Core de la BnF.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Les champs BnF ne sont pas exploitables bruts. Règles validées le 2026-08-17 (CLAUDE.md) :
|
||||
/// <list type="bullet">
|
||||
/// <item><c>dc:title</c> : <c>Germinal / Émile Zola ; préface…</c> → <c>Germinal</c></item>
|
||||
/// <item><c>dc:creator</c> : <c>Zola, Émile (1840-1902). Auteur du texte</c> → <c>Émile Zola</c></item>
|
||||
/// <item><c>dc:publisher</c> : <c>le Livre de poche (Paris)</c> → <c>le Livre de poche</c></item>
|
||||
/// </list>
|
||||
/// Les valeurs déjà propres doivent traverser ces règles <b>inchangées</b>.
|
||||
/// </remarks>
|
||||
public static partial class NettoyageIsbd
|
||||
{
|
||||
[GeneratedRegex(@"\s*\([^)]*\)")]
|
||||
private static partial Regex Parentheses();
|
||||
|
||||
[GeneratedRegex(@"\s*\([^)]*\)\s*$")]
|
||||
private static partial Regex ParenthesesFinales();
|
||||
|
||||
/// <summary>
|
||||
/// Titre : couper à la première mention de responsabilité (« / »).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auteur : retirer les dates entre parenthèses, le rôle qui suit le point,
|
||||
/// puis inverser « Nom, Prénom » en « Prénom Nom ».
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Éditeur : retirer la ville entre parenthèses en fin de chaîne.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retire les virgules et espaces de fin, et le point final <b>sauf</b> s'il appartient
|
||||
/// à une initiale (« Cormen, Thomas H. » doit garder son point).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Position du point qui introduit le rôle, ou -1.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using MaBibli.Shared.Dtos;
|
||||
|
||||
namespace MaBibli.Api.Services.Isbn;
|
||||
|
||||
public interface IOpenLibraryClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Cherche l'édition correspondant à un ISBN chez OpenLibrary, auteurs résolus.
|
||||
/// Renvoie une liste vide si l'ISBN est inconnu ou la source injoignable.
|
||||
/// </summary>
|
||||
Task<(IReadOnlyList<CandidatLivre> Candidats, string? Avertissement)> RechercherAsync(
|
||||
string isbn, string? urlCouverture, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client OpenLibrary — second rideau de la cascade, pour les livres étrangers et tout ce que
|
||||
/// la BnF ne connaît pas.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Bug à ne pas reproduire</b> (observé chez BookLogr) : <c>/isbn/{isbn}.json</c> ne donne pas
|
||||
/// le nom de l'auteur mais une référence <c>/authors/OL…A</c>. 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).
|
||||
/// </remarks>
|
||||
public sealed class OpenLibraryClient(HttpClient http, ILogger<OpenLibraryClient> 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<CandidatLivre> 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<OpenLibraryEdition>($"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<IReadOnlyList<string>> 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<OpenLibraryWork>(cleOeuvre.TrimStart('/') + ".json", ct);
|
||||
if (oeuvre is not null)
|
||||
{
|
||||
cles = OpenLibraryMapper.ClesAuteurs(oeuvre);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var noms = new List<string>();
|
||||
foreach (var cle in cles)
|
||||
{
|
||||
var auteur = await LireAsync<OpenLibraryAuthor>(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<T?> LireAsync<T>(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<T>(flux, Json, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using MaBibli.Shared.Dtos;
|
||||
|
||||
namespace MaBibli.Api.Services.Isbn;
|
||||
|
||||
/// <summary>
|
||||
/// Transformation d'une édition OpenLibrary en candidat. Fonctions pures, testables sans réseau.
|
||||
/// </summary>
|
||||
public static class OpenLibraryMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Clés d'auteurs portées par l'édition elle-même (<c>/authors/OL…A</c>), éventuellement vide.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ces clés ne sont <b>pas</b> des noms : chacune impose un appel à
|
||||
/// <c>/authors/{id}.json</c>. Oublier ce second appel donne un titre rempli et un auteur vide
|
||||
/// — symptôme exact observé chez BookLogr.
|
||||
/// </remarks>
|
||||
public static IReadOnlyList<string> ClesAuteurs(OpenLibraryEdition edition) =>
|
||||
edition.Authors?
|
||||
.Select(a => a.Key)
|
||||
.Where(k => !string.IsNullOrWhiteSpace(k))
|
||||
.Select(k => k!)
|
||||
.ToList() ?? [];
|
||||
|
||||
/// <summary>Clés d'auteurs portées par l'œuvre, utilisées en repli.</summary>
|
||||
public static IReadOnlyList<string> ClesAuteurs(OpenLibraryWork work) =>
|
||||
work.Authors?
|
||||
.Select(a => a.Author?.Key)
|
||||
.Where(k => !string.IsNullOrWhiteSpace(k))
|
||||
.Select(k => k!)
|
||||
.ToList() ?? [];
|
||||
|
||||
/// <summary>Clé de l'œuvre rattachée (<c>/works/OL…W</c>), ou <c>null</c>.</summary>
|
||||
public static string? CleOeuvre(OpenLibraryEdition edition) =>
|
||||
edition.Works?.Select(w => w.Key).FirstOrDefault(k => !string.IsNullOrWhiteSpace(k));
|
||||
|
||||
/// <summary>Nom affichable d'un auteur OpenLibrary.</summary>
|
||||
public static string? Nom(OpenLibraryAuthor auteur)
|
||||
{
|
||||
var nom = auteur.Name ?? auteur.PersonalName;
|
||||
return string.IsNullOrWhiteSpace(nom) ? null : nom.Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assemble le candidat final. <paramref name="nomsAuteurs"/> vient des appels
|
||||
/// <c>/authors/{id}.json</c> déjà effectués par le client.
|
||||
/// </summary>
|
||||
public static CandidatLivre? VersCandidat(
|
||||
OpenLibraryEdition edition,
|
||||
IReadOnlyList<string> 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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.]. »).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MaBibli.Api.Services.Isbn;
|
||||
|
||||
/// <summary>Réponse de <c>https://openlibrary.org/books/OL…M.json</c> (édition).</summary>
|
||||
public record OpenLibraryEdition
|
||||
{
|
||||
[JsonPropertyName("key")]
|
||||
public string? Key { get; init; }
|
||||
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; init; }
|
||||
|
||||
[JsonPropertyName("subtitle")]
|
||||
public string? Subtitle { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Références d'auteurs, <b>pas</b> des noms : <c>{ "key": "/authors/OL31901A" }</c>.
|
||||
/// 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).
|
||||
/// </summary>
|
||||
[JsonPropertyName("authors")]
|
||||
public List<OpenLibraryCle>? Authors { get; init; }
|
||||
|
||||
/// <summary>Œuvre rattachée, utilisée en repli quand l'édition ne porte aucun auteur.</summary>
|
||||
[JsonPropertyName("works")]
|
||||
public List<OpenLibraryCle>? Works { get; init; }
|
||||
|
||||
[JsonPropertyName("publishers")]
|
||||
public List<string>? Publishers { get; init; }
|
||||
|
||||
[JsonPropertyName("publish_date")]
|
||||
public string? PublishDate { get; init; }
|
||||
|
||||
[JsonPropertyName("languages")]
|
||||
public List<OpenLibraryCle>? Languages { get; init; }
|
||||
|
||||
/// <summary>Mention de responsabilité brute, dernier repli si aucun auteur n'est résolvable.</summary>
|
||||
[JsonPropertyName("by_statement")]
|
||||
public string? ByStatement { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Réponse de <c>https://openlibrary.org/works/OL…W.json</c>.</summary>
|
||||
public record OpenLibraryWork
|
||||
{
|
||||
[JsonPropertyName("authors")]
|
||||
public List<OpenLibraryWorkAuthor>? Authors { get; init; }
|
||||
}
|
||||
|
||||
public record OpenLibraryWorkAuthor
|
||||
{
|
||||
[JsonPropertyName("author")]
|
||||
public OpenLibraryCle? Author { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Réponse de <c>https://openlibrary.org/authors/OL…A.json</c>.</summary>
|
||||
public record OpenLibraryAuthor
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; init; }
|
||||
|
||||
[JsonPropertyName("personal_name")]
|
||||
public string? PersonalName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Référence OpenLibrary : <c>{ "key": "/authors/OL31901A" }</c>.</summary>
|
||||
public record OpenLibraryCle
|
||||
{
|
||||
[JsonPropertyName("key")]
|
||||
public string? Key { get; init; }
|
||||
}
|
||||
Reference in New Issue
Block a user