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,47 @@
|
||||
namespace MaBibli.Shared.Dtos;
|
||||
|
||||
/// <summary>Source d'où proviennent les métadonnées d'un candidat.</summary>
|
||||
public enum SourceMetadonnees
|
||||
{
|
||||
/// <summary>Catalogue général de la BnF, via son API SRU (Dublin Core).</summary>
|
||||
Bnf = 0,
|
||||
|
||||
/// <summary>OpenLibrary (Internet Archive), second rideau de la cascade.</summary>
|
||||
OpenLibrary = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Une notice candidate pour un ISBN donné.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Un même ISBN peut correspondre à <b>plusieurs</b> 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.
|
||||
/// </remarks>
|
||||
public record CandidatLivre
|
||||
{
|
||||
/// <summary>Titre nettoyé de la ponctuation ISBD.</summary>
|
||||
public required string Titre { get; init; }
|
||||
|
||||
/// <summary>Auteur au format « Prénom Nom », ou <c>null</c> si la source n'en donne pas.</summary>
|
||||
public string? Auteur { get; init; }
|
||||
|
||||
public string? Editeur { get; init; }
|
||||
|
||||
/// <summary>Année de publication telle que fournie par la source (texte : « 1979 », « March 2007 »…).</summary>
|
||||
public string? Annee { get; init; }
|
||||
|
||||
/// <summary>Code langue tel que fourni par la source (« fre », « eng »…).</summary>
|
||||
public string? Langue { get; init; }
|
||||
|
||||
/// <summary>URL de la couverture chez OpenLibrary, toujours renseignée (voir remarques du service).</summary>
|
||||
public string? CoverUrl { get; init; }
|
||||
|
||||
public required SourceMetadonnees Source { get; init; }
|
||||
|
||||
/// <summary>Forme d'ISBN qui a effectivement produit cette notice (13 ou 10 converti).</summary>
|
||||
public string? IsbnInterroge { get; init; }
|
||||
|
||||
/// <summary>Identifiant chez la source : ark BnF (<c>ark:/12148/cb…</c>) ou clé OpenLibrary (<c>/books/OL…M</c>).</summary>
|
||||
public string? IdentifiantSource { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace MaBibli.Shared.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// 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é.
|
||||
/// </summary>
|
||||
public record ResultatLookupIsbn
|
||||
{
|
||||
/// <summary>ISBN normalisé tel que demandé (tirets et espaces retirés).</summary>
|
||||
public required string IsbnDemande { get; init; }
|
||||
|
||||
/// <summary>ISBN-10 dérivé, si la conversion était possible (préfixe <c>978</c> uniquement).</summary>
|
||||
public string? Isbn10 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tous les candidats trouvés, dans l'ordre de la cascade. Peut être vide.
|
||||
/// La sélection revient à l'utilisateur.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<CandidatLivre> Candidats { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Messages non bloquants : source injoignable, réponse illisible…
|
||||
/// Une source en panne ne fait pas échouer la cascade, elle laisse une trace ici.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Avertissements { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using System.Text;
|
||||
|
||||
namespace MaBibli.Shared.Isbn;
|
||||
|
||||
/// <summary>
|
||||
/// Normalisation, validation et conversion des ISBN.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Piège bloquant documenté dans CLAUDE.md :</b> la BnF indexe l'ISBN <i>tel qu'imprimé</i>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public static class IsbnUtils
|
||||
{
|
||||
/// <summary>
|
||||
/// Retire tirets, espaces et points ; met le <c>x</c> final en majuscule.
|
||||
/// Renvoie <c>null</c> si l'entrée est vide.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Vrai si <paramref name="isbn"/> (déjà normalisé) est un ISBN-10 valide, clé comprise.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Vrai si <paramref name="isbn"/> (déjà normalisé) est un ISBN-13 / EAN-13 valide, clé comprise.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Vrai si l'ISBN normalisé est valide sous l'une ou l'autre forme.</summary>
|
||||
public static bool EstValide(string? isbn) => EstIsbn10Valide(isbn) || EstIsbn13Valide(isbn);
|
||||
|
||||
/// <summary>
|
||||
/// Convertit un ISBN-13 en ISBN-10.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Uniquement pour le préfixe <c>978</c> : les ISBN-13 en <c>979</c> n'ont aucun équivalent
|
||||
/// ISBN-10 et ne doivent pas être « convertis ». Le calcul suit CLAUDE.md : retirer <c>978</c>,
|
||||
/// garder les 9 chiffres, recalculer la clé (somme pondérée 10→2, modulo 11, <c>X</c> si 10).
|
||||
/// </remarks>
|
||||
/// <returns><c>true</c> et l'ISBN-10 en sortie, ou <c>false</c> si la conversion est impossible.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// URL de la couverture chez OpenLibrary, à utiliser <b>quelle que soit la source</b>
|
||||
/// des métadonnées : le Dublin Core de la BnF n'en fournit aucune.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Le <c>?default=false</c> est indispensable : sans lui OpenLibrary renvoie une image
|
||||
/// placeholder au lieu d'un 404, et on afficherait des couvertures vides sans le savoir.
|
||||
/// </remarks>
|
||||
public static string UrlCouverture(string isbn) =>
|
||||
$"https://covers.openlibrary.org/b/isbn/{isbn}-L.jpg?default=false";
|
||||
}
|
||||
Reference in New Issue
Block a user