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:
mathieu
2026-08-17 22:22:47 +02:00
co-authored by Claude Opus 5
parent 4990e41f3e
commit f8f30dbcf7
37 changed files with 1661 additions and 0 deletions
+62
View File
@@ -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}.");
}
}
}
+74
View File
@@ -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>&lt;srw:numberOfRecords&gt;</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);
}
}
}
+156
View File
@@ -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; }
}