/**
* LibraryThing Service Provider
*
* Provides edition variant discovery via LibraryThing's thingISBN API.
* Community-validated edition relationships from 2M+ LibraryThing users.
*
* Implements:
* - IEditionVariantProvider: ISBN → Related ISBNs (editions, formats, translations)
*
* Features:
* - Free for non-commercial use
* - Rate limit: 1,000 requests/day, 1 request per second
* - KV-backed rate limiting and caching
* - 30-day cache TTL (edition data is stable)
* - Graceful error handling (returns empty array, never throws)
*
* API Documentation:
* - Endpoint: https://www.librarything.com/api/{key}/thingISBN/{isbn}
* - Format: XML response with related ISBNs
* - Optional allids=1 parameter for LCCN/OCLC numbers
*
* @see https://www.librarything.com/services/webservices.php
* @see https://wiki.librarything.com/index.php/LibraryThing_APIs
* @module lib/external-services/providers/librarything-provider
*/
import type {
IEditionVariantProvider,
EditionVariant,
} from '../capabilities.js';
import type { ServiceContext } from '../service-context.js';
import type { Env } from '../../../src/env.js';
import { ServiceHttpClient } from '../http-client.js';
import { ServiceCapability } from '../capabilities.js';
import { normalizeISBN } from '../../isbn-utils.js';
// =================================================================================
// Constants
// =================================================================================
/**
* LibraryThing thingISBN API base URL
*/
const LIBRARYTHING_API_BASE = 'https://www.librarything.com/api';
// =================================================================================
// Types
// =================================================================================
/**
* LibraryThing thingISBN API response structure
*
* XML format:
*
* 9780441172719
* 0441172717
* ...
*
*
* We'll parse the XML to extract ISBNs
*/
interface ThingISBNResponse {
isbns: string[];
}
// =================================================================================
// LibraryThing Provider
// =================================================================================
/**
* LibraryThing Service Provider
*
* Free service for non-commercial use.
* Rate limited to 1 req/sec (1,000 req/day).
*/
export class LibraryThingProvider implements IEditionVariantProvider {
readonly name = 'librarything';
readonly providerType = 'free' as const;
readonly capabilities = [ServiceCapability.EDITION_VARIANTS];
private client = new ServiceHttpClient({
providerName: 'librarything',
rateLimitMs: 1000, // 1 req/sec to stay well under limit
cacheTtlSeconds: 2592000, // 30 days (edition data is stable)
purpose: 'Edition disambiguation and variant discovery',
});
/**
* Check if LibraryThing is available
* Requires API key in environment
*/
async isAvailable(env: Env, _quotaManager?: import("../../../src/services/quota-manager.js").QuotaManager): Promise {
const apiKey = await env.LIBRARYTHING_API_KEY?.get();
return !!apiKey;
}
/**
* Fetch edition variants (related ISBNs) for a given ISBN
*
* Uses thingISBN API to find other editions, formats, and translations
* of the same work based on LibraryThing's community-validated data.
*
* @param isbn - The ISBN to look up
* @param context - Service context with logger and env
* @returns Array of edition variants (may be empty if none found)
*/
async fetchEditionVariants(isbn: string, context: ServiceContext): Promise {
const { logger, env } = context;
try {
// Validate ISBN format
const normalizedISBN = normalizeISBN(isbn);
if (!normalizedISBN) {
logger.debug('Invalid ISBN format for LibraryThing edition variants', { isbn });
return [];
}
// Get API key
const apiKey = await env.LIBRARYTHING_API_KEY?.get();
if (!apiKey) {
logger.error('LibraryThing API key not configured');
return [];
}
// Build thingISBN API URL
const url = `${LIBRARYTHING_API_BASE}/${apiKey}/thingISBN/${normalizedISBN}`;
// Fetch XML response
const xmlResponse = await this.client.fetch(
url,
{
headers: {
'Accept': 'application/xml, text/xml',
},
},
context,
'text' // Request text response instead of JSON
);
if (!xmlResponse) {
logger.debug('No response from LibraryThing thingISBN API', { isbn });
return [];
}
// Parse XML to extract ISBNs
const relatedISBNs = this.parseThingISBNResponse(xmlResponse);
if (relatedISBNs.length === 0) {
logger.debug('No edition variants found via LibraryThing', { isbn });
return [];
}
// Convert ISBNs to EditionVariant objects
// Note: LibraryThing doesn't provide format info, so we mark as 'other'
const variants: EditionVariant[] = relatedISBNs
.filter((relatedIsbn) => relatedIsbn !== normalizedISBN) // Exclude the original ISBN
.map((relatedIsbn) => ({
isbn: relatedIsbn,
format: 'other' as const, // LibraryThing doesn't provide format info
formatDescription: 'Related edition from LibraryThing',
source: 'librarything',
}));
logger.info('LibraryThing edition variants fetched', {
isbn,
variantCount: variants.length,
totalRelated: relatedISBNs.length,
});
return variants;
} catch (error) {
logger.error('LibraryThing edition variants fetch failed', {
isbn,
error: error instanceof Error ? error.message : String(error),
});
return [];
}
}
/**
* Parse thingISBN XML response to extract ISBNs
*
* XML format:
*
* 9780441172719
* 0441172717
*
*
* @param xml - XML response string
* @returns Array of ISBNs
*/
private parseThingISBNResponse(xml: string): string[] {
const isbns: string[] = [];
try {
// Simple regex-based XML parsing (safe for this simple structure)
// Match all ... tags
const isbnRegex = /([^<]+)<\/isbn>/gi;
let match: RegExpExecArray | null;
while ((match = isbnRegex.exec(xml)) !== null) {
const isbn = match[1].trim();
if (isbn) {
isbns.push(isbn);
}
}
return isbns;
} catch (error) {
// If parsing fails, return empty array (graceful degradation)
return [];
}
}
}