// OpenLibraryScraper.js class OpenLibraryScraper { constructor(options = {}) { this.baseURL = options.baseURL || 'https://openlibrary.org'; this.coverURL = options.coverURL || 'https://covers.openlibrary.org'; this.archiveURL = options.archiveURL || 'https://archive.org'; this.userAgent = options.userAgent || 'OpenLibraryScraper/1.0 megawatihamil@gmail.com'; this.baseDelay = options.baseDelay || 1000; this.maxRetries = options.maxRetries || 3; this.timeout = options.timeout || 15000; this.lastRequestTime = 0; } _sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } async _request(path, options = {}) { const now = Date.now(); const elapsed = now - this.lastRequestTime; if (elapsed < this.baseDelay) { await this._sleep(this.baseDelay - elapsed); } const url = path.startsWith('http') ? path : `${this.baseURL}${path}`; for (let attempt = 1; attempt <= this.maxRetries; attempt++) { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeout); const response = await fetch(url, { ...options, headers: { 'User-Agent': this.userAgent, 'Accept': 'application/json', ...options.headers, }, signal: controller.signal, }); clearTimeout(timeoutId); this.lastRequestTime = Date.now(); if (response.status === 429 || response.status >= 500) { if (attempt === this.maxRetries) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const backoff = this.baseDelay * Math.pow(2, attempt); console.warn(`⏳ Retry ${attempt}/${this.maxRetries} (HTTP ${response.status}) - tunggu ${backoff}ms`); await this._sleep(backoff); continue; } if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const text = await response.text(); try { return JSON.parse(text); } catch { throw new Error(`Response bukan JSON valid: ${text.substring(0, 100)}`); } } catch (error) { if (attempt === this.maxRetries) throw error; const backoff = this.baseDelay * Math.pow(2, attempt); console.warn(`⏳ Retry ${attempt}/${this.maxRetries}: ${error.message}`); await this._sleep(backoff); } } } _normalizeBook(doc) { if (!doc) return null; // --- Extract Authors --- let authors = []; let authorKeys = []; if (Array.isArray(doc.author_name)) { authors = doc.author_name; authorKeys = (doc.author_key || []).map(k => k.startsWith('/authors/') ? k.split('/').pop() : k ); } else if (Array.isArray(doc.authors)) { authors = doc.authors.map(a => { if (a.name) return a.name; if (a.author?.name) return a.author.name; return null; }).filter(Boolean); authorKeys = doc.authors.map(a => { const rawKey = a.key || a.author?.key; if (!rawKey) return null; return rawKey.startsWith('/authors/') ? rawKey.split('/').pop() : rawKey; }).filter(Boolean); } let coverId = doc.cover_i || doc.cover_id || null; if (!coverId && Array.isArray(doc.covers) && doc.covers.length > 0) { coverId = doc.covers[0]; } if (!coverId && Array.isArray(doc.photos) && doc.photos.length > 0) { coverId = doc.photos[0]; } const isbn10 = doc.isbn_10 || []; const isbn13 = doc.isbn_13 || []; let description = doc.description; if (typeof description === 'object' && description !== null) { description = description.value; } let olid = null; if (doc.key) { olid = doc.key.split('/').pop(); } let readUrl = null; if (doc.key) { readUrl = `${this.baseURL}${doc.key}`; } let borrowUrl = null; let canBorrow = false; // Identifier Internet Archive (ia / ocaid) const iaIdentifier = doc.ia?.[0] || doc.ocaid || doc.identifier?.ia?.[0] || null; if (iaIdentifier) { borrowUrl = `${this.archiveURL}/details/${iaIdentifier}`; canBorrow = true; } if (doc.availability) { canBorrow = doc.availability.is_readable || doc.availability.is_browseable || false; if (doc.availability.identifier && !borrowUrl) { borrowUrl = `${this.archiveURL}/details/${doc.availability.identifier}`; } } let previewUrl = null; if (olid && doc.key?.startsWith('/books/')) { previewUrl = `${this.baseURL}/books/${olid}/-/preview`; } let borrowDirectUrl = null; if (canBorrow && iaIdentifier) { borrowDirectUrl = `${this.baseURL}/borrow/ia/${iaIdentifier}`; } return { key: doc.key, olid, title: doc.title, subtitle: doc.subtitle || null, authors, authorKeys, firstPublishYear: doc.first_publish_year || null, publishDate: doc.publish_date || null, publishers: doc.publishers || doc.publish || [], languages: (doc.languages || []).map(l => typeof l === 'string' ? l : (l.key || '').split('/').pop() ), subjects: doc.subjects || [], isbn10, isbn13, isbn: isbn13[0] || isbn10[0] || null, pages: doc.number_of_pages || null, coverId, coverUrl: coverId ? `${this.coverURL}/b/id/${coverId}-L.jpg` : null, description, editionsCount: doc.edition_count || null, readUrl, borrowUrl, borrowDirectUrl, previewUrl, canBorrow, iaIdentifier, raw: doc, }; } /** * Resolve author name dari author key * @private */ async _resolveAuthorNames(authorKeys) { if (!authorKeys || authorKeys.length === 0) return []; const names = []; for (const key of authorKeys) { try { const data = await this._request(`/authors/${key}.json`); names.push(data.name || 'Unknown'); } catch { names.push('Unknown'); } } return names; } async search(query, options = {}) { const params = new URLSearchParams(); params.set('q', query); params.set('page', String(options.page || 1)); params.set('limit', String(options.limit || 25)); if (options.fields) params.set('fields', options.fields); if (options.author) params.set('author', options.author); if (options.title) params.set('title', options.title); if (options.subject) params.set('subject', options.subject); if (options.language) params.set('language', options.language); if (options.sort) params.set('sort', options.sort); const data = await this._request(`/search.json?${params.toString()}`); return { totalResults: data.numFound || 0, start: data.start, page: options.page || 1, books: (data.docs || []).map(doc => this._normalizeBook(doc)), }; } async searchAuthor(query, options = {}) { const params = new URLSearchParams({ q: query }); params.set('page', String(options.page || 1)); params.set('limit', String(options.limit || 25)); const data = await this._request(`/search/authors.json?${params.toString()}`); return { totalResults: data.numFound || 0, authors: (data.docs || []).map(a => ({ key: a.key, name: a.name, birthDate: a.birth_date || null, deathDate: a.death_date || null, topWork: a.top_work || null, workCount: a.work_count || 0, })), }; } async getByISBN(isbn) { const clean = isbn.replace(/[-\s]/g, ''); const data = await this._request(`/isbn/${clean}.json`); const book = this._normalizeBook(data); if (book && book.authors.length === 0 && book.authorKeys.length > 0) { book.authors = await this._resolveAuthorNames(book.authorKeys); } return book; } async getByOLID(olid) { const id = olid.startsWith('OL') ? olid : `OL${olid}`; const data = await this._request(`/books/${id}.json`); const book = this._normalizeBook(data); if (book && book.authors.length === 0 && book.authorKeys.length > 0) { book.authors = await this._resolveAuthorNames(book.authorKeys); } return book; } async getByOCLC(oclc) { const data = await this._request(`/books/oclc/${oclc}.json`); return this._normalizeBook(data); } async getWork(workId) { const id = workId.startsWith('OL') ? (workId.endsWith('W') ? workId : `${workId}W`) : `OL${workId}W`; const data = await this._request(`/works/${id}.json`); const book = this._normalizeBook(data); if (book && book.authors.length === 0 && book.authorKeys.length > 0) { book.authors = await this._resolveAuthorNames(book.authorKeys); } return book; } async getEditions(workId, options = {}) { const id = workId.startsWith('OL') ? (workId.endsWith('W') ? workId : `${workId}W`) : `OL${workId}W`; const params = new URLSearchParams({ limit: String(options.limit || 50), }); const data = await this._request(`/works/${id}/editions.json?${params.toString()}`); return { total: data.size || 0, editions: (data.entries || []).map(e => this._normalizeBook(e)), }; } async getAuthor(authorId) { const id = authorId.startsWith('OL') ? (authorId.endsWith('A') ? authorId : `${authorId}A`) : `OL${authorId}A`; const data = await this._request(`/authors/${id}.json`); let bio = data.bio; if (typeof bio === 'object' && bio !== null) { bio = bio.value; } return { key: data.key, olid: data.key?.split('/').pop(), name: data.name, birthDate: data.birth_date || null, deathDate: data.death_date || null, bio, wikipedia: data.wikipedia || null, photos: data.photos || [], links: data.links || [], profileUrl: `${this.baseURL}${data.key}`, }; } async getAuthorWorks(authorId, options = {}) { const id = authorId.startsWith('OL') ? (authorId.endsWith('A') ? authorId : `${authorId}A`) : `OL${authorId}A`; const params = new URLSearchParams({ limit: String(options.limit || 50), offset: String(options.offset || 0), }); const data = await this._request(`/authors/${id}/works.json?${params.toString()}`); const works = (data.entries || []).map(w => { const authors = (w.authors || []).map(a => { if (a.name) return a.name; if (a.author?.name) return a.author.name; return null; }).filter(Boolean); const coverId = w.cover_id || null; return { key: w.key, title: w.title, authors, firstPublishYear: w.first_publish_year || null, coverId, coverUrl: coverId ? `${this.coverURL}/b/id/${coverId}-L.jpg` : null, editionCount: w.edition_count || 0, readUrl: `${this.baseURL}${w.key}`, }; }); return { total: data.size || 0, works, }; } async getSubject(subject, options = {}) { const params = new URLSearchParams({ limit: String(options.limit || 25), offset: String(options.offset || 0), }); if (options.details) params.set('details', 'true'); const cleanSubject = subject.toLowerCase().replace(/\s+/g, '_'); const data = await this._request(`/subjects/${cleanSubject}.json?${params.toString()}`); return { name: data.name, workCount: data.work_count || 0, subjectUrl: `${this.baseURL}/subjects/${cleanSubject}`, books: (data.works || []).map(w => ({ key: w.key, title: w.title, authors: (w.authors || []).map(a => a.name).filter(Boolean), coverId: w.cover_id || null, coverUrl: w.cover_id ? `${this.coverURL}/b/id/${w.cover_id}-L.jpg` : null, firstPublishYear: w.first_publish_year || null, editionCount: w.edition_count || 0, readUrl: `${this.baseURL}${w.key}`, })), }; } async getSubjectList(type = 'subject') { const data = await this._request(`/subjects.json?limit=100`); return data.subjects || []; } async getRecentAdditions(limit = 50) { const data = await this._request(`/recentadditions.json?limit=${limit}`); return { total: data.numFound || 0, books: (data.docs || []).map(doc => this._normalizeBook(doc)), }; } async getTrendingBooks(timeframe = 'daily', options = {}) { const validTimeframes = ['daily', 'monthly', 'yearly', 'alltime']; if (!validTimeframes.includes(timeframe)) timeframe = 'daily'; const data = await this._request(`/trending/${timeframe}.json`); const works = Array.isArray(data) ? data : (data.works || data.docs || []); let books = works.map(w => { const coverId = w.cover_id || null; return { key: w.key, title: w.title, authors: (w.authors || []).map(a => a.name || a.author?.name).filter(Boolean), coverId, coverUrl: coverId ? `${this.coverURL}/b/id/${coverId}-L.jpg` : null, editionCount: w.edition_count || 0, readUrl: w.key ? `${this.baseURL}${w.key}` : null, borrowUrl: null, canBorrow: false, }; }); if (options.fetchDetails) { console.warn(`⚠️ Fetching details untuk ${books.length} buku trending...`); for (let i = 0; i < books.length; i++) { const bookKey = books[i].key; if (!bookKey) continue; try { const workId = bookKey.split('/').pop(); const detail = await this.getWork(workId); if (detail) { books[i].authors = detail.authors; books[i].coverId = detail.coverId; books[i].coverUrl = detail.coverUrl; books[i].borrowUrl = detail.borrowUrl; books[i].canBorrow = detail.canBorrow; books[i].iaIdentifier = detail.iaIdentifier; } } catch (err) { // Abaikan error per buku } } } return { timeframe, books, }; } getCoverUrl(id, type = 'id', size = 'L') { const validSizes = ['S', 'M', 'L']; const validTypes = ['isbn', 'olid', 'oclc', 'id']; if (!validSizes.includes(size)) size = 'L'; if (!validTypes.includes(type)) type = 'id'; return `${this.coverURL}/b/${type}/${id}-${size}.jpg`; } /** * Dapatkan SEMUA link baca buku dari berbagai sumber * Termasuk: Internet Archive, Amazon, Perpustakaan, dll * @param {string} identifier - OLID (OL22597282M) atau ISBN atau IA identifier * @param {string} type - "olid" | "isbn" | "oclc" | "ia" * @returns {Promise} Semua link baca yang tersedia */ async getReadLinks(identifier, type = 'olid') { const links = { openLibrary: null, internetArchive: null, preview: null, external: [], }; try { let book = null; if (type === 'isbn') { book = await this.getByISBN(identifier); } else if (type === 'oclc') { book = await this.getByOCLC(identifier); } else if (type === 'olid') { book = await this.getByOLID(identifier); } else if (type === 'ia') { // Langsung IA identifier links.internetArchive = `${this.archiveURL}/details/${identifier}`; links.openLibrary = `${this.baseURL}/ia:${identifier}`; } if (book) { links.openLibrary = book.readUrl; links.internetArchive = book.borrowUrl; links.preview = book.previewUrl; if (book.raw) { // 1. Link dari field `links` if (Array.isArray(book.raw.links)) { book.raw.links.forEach(link => { links.external.push({ title: link.title || 'External Link', url: link.url, }); }); } if (book.raw.identifiers) { const ids = book.raw.identifiers; if (ids.goodreads?.length > 0) { links.external.push({ title: 'Goodreads', url: `https://www.goodreads.com/book/show/${ids.goodreads[0]}`, }); } if (ids.librarything?.length > 0) { links.external.push({ title: 'LibraryThing', url: `https://www.librarything.com/work/${ids.librarything[0]}`, }); } if (ids.wikidata?.length > 0) { links.external.push({ title: 'Wikidata', url: `https://www.wikidata.org/wiki/${ids.wikidata[0]}`, }); } if (ids.amazon?.length > 0) { links.external.push({ title: 'Amazon', url: `https://www.amazon.com/dp/${ids.amazon[0]}`, }); } } if (book.isbn) { links.external.push({ title: 'WorldCat (Perpustakaan Terdekat)', url: `https://www.worldcat.org/isbn/${book.isbn}`, }); links.external.push({ title: 'Google Books', url: `https://books.google.com/books?vid=ISBN${book.isbn}`, }); links.external.push({ title: 'Open Library (by ISBN)', url: `${this.baseURL}/isbn/${book.isbn}`, }); } } } const seen = new Set(); links.external = links.external.filter(link => { if (seen.has(link.url)) return false; seen.add(link.url); return true; }); return links; } catch (error) { return { error: error.message, openLibrary: null, internetArchive: null, preview: null, external: [], }; } } async getBorrowableEditions(workId) { const editions = await this.getEditions(workId, { limit: 100 }); return editions.editions.filter(ed => ed.canBorrow && ed.borrowUrl); } async advancedSearch(filters = {}) { const params = new URLSearchParams(); if (filters.query) params.set('q', filters.query); if (filters.title) params.set('title', filters.title); if (filters.author) params.set('author', filters.author); if (filters.subject) params.set('subject', filters.subject); if (filters.publisher) params.set('publisher', filters.publisher); if (filters.isbn) params.set('isbn', filters.isbn); if (filters.language) params.set('language', filters.language); if (filters.yearFrom) { params.set('first_publish_year', `${filters.yearFrom}`); } params.set('page', String(filters.page || 1)); params.set('limit', String(filters.limit || 25)); if (filters.sort) params.set('sort', filters.sort); const data = await this._request(`/search.json?${params.toString()}`); let books = (data.docs || []).map(doc => this._normalizeBook(doc)); if (filters.yearFrom && filters.yearTo) { books = books.filter(b => b.firstPublishYear !== null && b.firstPublishYear >= filters.yearFrom && b.firstPublishYear <= filters.yearTo ); } return { totalResults: data.numFound || 0, books, }; } async hasISBN(isbn) { try { const book = await this.getByISBN(isbn); return !!book; } catch { return false; } } async batchSearch(queries, options = {}) { const results = {}; for (const query of queries) { try { results[query] = await this.search(query, { limit: options.limit || 5 }); } catch (err) { results[query] = { error: err.message }; } } return results; } /** * Cek apakah buku bisa dibaca gratis di Internet Archive * @param {string} isbn atau OLID * @param {string} type - "isbn" | "olid" * @returns {Promise} */ async canReadOnline(identifier, type = 'isbn') { const book = type === 'isbn' ? await this.getByISBN(identifier) : await this.getByOLID(identifier); if (!book) { return { readable: false, reason: 'Book not found' }; } if (book.canBorrow && book.borrowUrl) { return { readable: true, title: book.title, borrowUrl: book.borrowUrl, previewUrl: book.previewUrl, readUrl: book.readUrl, iaIdentifier: book.iaIdentifier, }; } return { readable: false, title: book.title, readUrl: book.readUrl, reason: 'Not available for online reading', }; } } if (typeof module !== 'undefined' && module.exports) { module.exports = OpenLibraryScraper; } const scraper = new OpenLibraryScraper({ userAgent: 'MyBookApp/1.0 MegawatiHamil@gmail.com', // ⚠️ GANTI Email Atau Biarin Pake Gmail Ini baseDelay: 1500, }); async function OpenLibryRun() { try { console.log('\n🔍 SEARCH DENGAN READ URL...'); const search = await scraper.search('Dune Frank Herbert', { limit: 3 }); search.books.forEach((book, i) => { console.log(`\n${i + 1}. ${book.title} (${book.firstPublishYear})`); console.log(` 👤 Penulis: ${book.authors.join(', ')}`); console.log(` 📖 Baca di OL: ${book.readUrl}`); console.log(` 📥 Borrow/Read Online: ${book.borrowUrl || 'Tidak tersedia'}`); console.log(` ✅ Bisa dibaca: ${book.canBorrow ? 'Ya' : 'Tidak'}`); if (book.coverUrl) console.log(` 🖼️ Cover: ${book.coverUrl}`); }); console.log('\n📖 DETAIL BUKU (ISBN 9780441172719)...'); const book = await scraper.getByISBN('9780441172719'); if (book) { console.log(`Judul: ${book.title}`); console.log(`Penulis: ${book.authors.join(', ')}`); console.log('\n🔗 SEMUA URL BACA:'); console.log(` 📖 Halaman OL: ${book.readUrl}`); console.log(` 📥 Internet Archive: ${book.borrowUrl || 'Tidak ada'}`); console.log(` 📥 Borrow Direct: ${book.borrowDirectUrl || 'Tidak ada'}`); console.log(` 👁️ Preview: ${book.previewUrl || 'Tidak ada'}`); console.log(` ✅ Can Borrow: ${book.canBorrow}`); console.log(` 🆔 IA Identifier: ${book.iaIdentifier || 'Tidak ada'}`); } console.log('\n🔗 ALL READ LINKS (ISBN 9780441172719)...'); const readLinks = await scraper.getReadLinks('9780441172719', 'isbn'); console.log('📖 Open Library:', readLinks.openLibrary); console.log('📥 Internet Archive:', readLinks.internetArchive); console.log('👁️ Preview:', readLinks.preview); console.log('🌐 External Links:'); readLinks.external.forEach(link => { console.log(` • ${link.title}: ${link.url}`); }); console.log('\n✅ CEK KETERBACAAN...'); const readable = await scraper.canReadOnline('9780441172719', 'isbn'); console.log(`Readability: ${JSON.stringify(readable, null, 2)}`); console.log('\n📚 EDISI YANG BISA DIPINJAM (Dune)...'); const borrowable = await scraper.getBorrowableEditions('OL893414W'); console.log(`Ditemukan ${borrowable.length} edisi yang bisa dibaca online`); borrowable.slice(0, 5).forEach((ed, i) => { console.log(`\n${i + 1}. ${ed.title} (${ed.publishDate})`); console.log(` 📥 Baca: ${ed.borrowUrl}`); }); console.log('\n📚 BUKU KLASIK (biasanya bisa dibaca gratis)...'); const classics = await scraper.search('Pride and Prejudice Jane Austen', { limit: 1 }); if (classics.books[0]) { const cls = classics.books[0]; console.log(`\n${cls.title}`); console.log(`📖 Baca di OL: ${cls.readUrl}`); console.log(`📥 Baca di Archive: ${cls.borrowUrl || 'Tidak tersedia'}`); const borrowEds = await scraper.getBorrowableEditions(cls.olid); if (borrowEds.length > 0) { console.log(`\n✅ ${borrowEds.length} edisi tersedia untuk dibaca:`); borrowEds.slice(0, 3).forEach(ed => { console.log(` • ${ed.title} (${ed.publishDate}) → ${ed.borrowUrl}`); }); } } } catch (error) { console.error('❌ Error:', error.message); } } OpenLibryRun();