openLibry Scraper

JavaScriptPublic
by zx-apiAug 23, 2026, 05:18 AMExpires: Never136 views
openLibry Scraper
Raw
1// OpenLibraryScraper.js
2
3class OpenLibraryScraper {
4    constructor(options = {}) {
5        this.baseURL = options.baseURL || 'https://openlibrary.org';
6        this.coverURL = options.coverURL || 'https://covers.openlibrary.org';
7        this.archiveURL = options.archiveURL || 'https://archive.org';
8        this.userAgent = options.userAgent || 'OpenLibraryScraper/1.0 megawatihamil@gmail.com';
9        this.baseDelay = options.baseDelay || 1000;
10        this.maxRetries = options.maxRetries || 3;
11        this.timeout = options.timeout || 15000;
12        this.lastRequestTime = 0;
13    }
14
15    _sleep(ms) {
16        return new Promise(resolve => setTimeout(resolve, ms));
17    }
18
19    async _request(path, options = {}) {
20        const now = Date.now();
21        const elapsed = now - this.lastRequestTime;
22        if (elapsed < this.baseDelay) {
23            await this._sleep(this.baseDelay - elapsed);
24        }
25
26        const url = path.startsWith('http') ? path : `${this.baseURL}${path}`;
27
28        for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
29            try {
30                const controller = new AbortController();
31                const timeoutId = setTimeout(() => controller.abort(), this.timeout);
32
33                const response = await fetch(url, {
34                    ...options,
35                    headers: {
36                        'User-Agent': this.userAgent,
37                        'Accept': 'application/json',
38                        ...options.headers,
39                    },
40                    signal: controller.signal,
41                });
42
43                clearTimeout(timeoutId);
44                this.lastRequestTime = Date.now();
45
46                if (response.status === 429 || response.status >= 500) {
47                    if (attempt === this.maxRetries) {
48                        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
49                    }
50                    const backoff = this.baseDelay * Math.pow(2, attempt);
51                    console.warn(`โณ Retry ${attempt}/${this.maxRetries} (HTTP ${response.status}) - tunggu ${backoff}ms`);
52                    await this._sleep(backoff);
53                    continue;
54                }
55
56                if (!response.ok) {
57                    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
58                }
59
60                const text = await response.text();
61                try {
62                    return JSON.parse(text);
63                } catch {
64                    throw new Error(`Response bukan JSON valid: ${text.substring(0, 100)}`);
65                }
66
67            } catch (error) {
68                if (attempt === this.maxRetries) throw error;
69                const backoff = this.baseDelay * Math.pow(2, attempt);
70                console.warn(`โณ Retry ${attempt}/${this.maxRetries}: ${error.message}`);
71                await this._sleep(backoff);
72            }
73        }
74    }
75
76    _normalizeBook(doc) {
77        if (!doc) return null;
78
79        // --- Extract Authors ---
80        let authors = [];
81        let authorKeys = [];
82
83        if (Array.isArray(doc.author_name)) {
84            authors = doc.author_name;
85            authorKeys = (doc.author_key || []).map(k =>
86                k.startsWith('/authors/') ? k.split('/').pop() : k
87            );
88        } else if (Array.isArray(doc.authors)) {
89            authors = doc.authors.map(a => {
90                if (a.name) return a.name;
91                if (a.author?.name) return a.author.name;
92                return null;
93            }).filter(Boolean);
94
95            authorKeys = doc.authors.map(a => {
96                const rawKey = a.key || a.author?.key;
97                if (!rawKey) return null;
98                return rawKey.startsWith('/authors/') ? rawKey.split('/').pop() : rawKey;
99            }).filter(Boolean);
100        }
101
102        let coverId = doc.cover_i || doc.cover_id || null;
103        if (!coverId && Array.isArray(doc.covers) && doc.covers.length > 0) {
104            coverId = doc.covers[0];
105        }
106        if (!coverId && Array.isArray(doc.photos) && doc.photos.length > 0) {
107            coverId = doc.photos[0];
108        }
109
110        const isbn10 = doc.isbn_10 || [];
111        const isbn13 = doc.isbn_13 || [];
112
113        let description = doc.description;
114        if (typeof description === 'object' && description !== null) {
115            description = description.value;
116        }
117
118        let olid = null;
119        if (doc.key) {
120            olid = doc.key.split('/').pop();
121        }
122
123
124        let readUrl = null;
125        if (doc.key) {
126            readUrl = `${this.baseURL}${doc.key}`;
127        }
128
129        let borrowUrl = null;
130        let canBorrow = false;
131        
132        // Identifier Internet Archive (ia / ocaid)
133        const iaIdentifier = doc.ia?.[0] || doc.ocaid || doc.identifier?.ia?.[0] || null;
134        if (iaIdentifier) {
135            borrowUrl = `${this.archiveURL}/details/${iaIdentifier}`;
136            canBorrow = true;
137        }
138
139        if (doc.availability) {
140            canBorrow = doc.availability.is_readable || doc.availability.is_browseable || false;
141            if (doc.availability.identifier && !borrowUrl) {
142                borrowUrl = `${this.archiveURL}/details/${doc.availability.identifier}`;
143            }
144        }
145
146        let previewUrl = null;
147        if (olid && doc.key?.startsWith('/books/')) {
148            previewUrl = `${this.baseURL}/books/${olid}/-/preview`;
149        }
150
151        let borrowDirectUrl = null;
152        if (canBorrow && iaIdentifier) {
153            borrowDirectUrl = `${this.baseURL}/borrow/ia/${iaIdentifier}`;
154        }
155
156        return {
157            key: doc.key,
158            olid,
159            title: doc.title,
160            subtitle: doc.subtitle || null,
161            authors,
162            authorKeys,
163            firstPublishYear: doc.first_publish_year || null,
164            publishDate: doc.publish_date || null,
165            publishers: doc.publishers || doc.publish || [],
166            languages: (doc.languages || []).map(l =>
167                typeof l === 'string' ? l : (l.key || '').split('/').pop()
168            ),
169            subjects: doc.subjects || [],
170            isbn10,
171            isbn13,
172            isbn: isbn13[0] || isbn10[0] || null,
173            pages: doc.number_of_pages || null,
174            coverId,
175            coverUrl: coverId ? `${this.coverURL}/b/id/${coverId}-L.jpg` : null,
176            description,
177            editionsCount: doc.edition_count || null,
178            readUrl,           
179            borrowUrl,         
180            borrowDirectUrl,  
181            previewUrl,        
182            canBorrow,        
183            iaIdentifier,     
184            raw: doc,
185        };
186    }
187
188    /**
189     * Resolve author name dari author key
190     * @private
191     */
192    async _resolveAuthorNames(authorKeys) {
193        if (!authorKeys || authorKeys.length === 0) return [];
194
195        const names = [];
196        for (const key of authorKeys) {
197            try {
198                const data = await this._request(`/authors/${key}.json`);
199                names.push(data.name || 'Unknown');
200            } catch {
201                names.push('Unknown');
202            }
203        }
204        return names;
205    }
206
207    async search(query, options = {}) {
208        const params = new URLSearchParams();
209        params.set('q', query);
210        params.set('page', String(options.page || 1));
211        params.set('limit', String(options.limit || 25));
212
213        if (options.fields) params.set('fields', options.fields);
214        if (options.author) params.set('author', options.author);
215        if (options.title) params.set('title', options.title);
216        if (options.subject) params.set('subject', options.subject);
217        if (options.language) params.set('language', options.language);
218        if (options.sort) params.set('sort', options.sort);
219
220        const data = await this._request(`/search.json?${params.toString()}`);
221
222        return {
223            totalResults: data.numFound || 0,
224            start: data.start,
225            page: options.page || 1,
226            books: (data.docs || []).map(doc => this._normalizeBook(doc)),
227        };
228    }
229
230    async searchAuthor(query, options = {}) {
231        const params = new URLSearchParams({ q: query });
232        params.set('page', String(options.page || 1));
233        params.set('limit', String(options.limit || 25));
234
235        const data = await this._request(`/search/authors.json?${params.toString()}`);
236
237        return {
238            totalResults: data.numFound || 0,
239            authors: (data.docs || []).map(a => ({
240                key: a.key,
241                name: a.name,
242                birthDate: a.birth_date || null,
243                deathDate: a.death_date || null,
244                topWork: a.top_work || null,
245                workCount: a.work_count || 0,
246            })),
247        };
248    }
249
250    async getByISBN(isbn) {
251        const clean = isbn.replace(/[-\s]/g, '');
252        const data = await this._request(`/isbn/${clean}.json`);
253        const book = this._normalizeBook(data);
254
255        if (book && book.authors.length === 0 && book.authorKeys.length > 0) {
256            book.authors = await this._resolveAuthorNames(book.authorKeys);
257        }
258
259        return book;
260    }
261
262    async getByOLID(olid) {
263        const id = olid.startsWith('OL') ? olid : `OL${olid}`;
264        const data = await this._request(`/books/${id}.json`);
265        const book = this._normalizeBook(data);
266
267        if (book && book.authors.length === 0 && book.authorKeys.length > 0) {
268            book.authors = await this._resolveAuthorNames(book.authorKeys);
269        }
270
271        return book;
272    }
273
274    async getByOCLC(oclc) {
275        const data = await this._request(`/books/oclc/${oclc}.json`);
276        return this._normalizeBook(data);
277    }
278
279    async getWork(workId) {
280        const id = workId.startsWith('OL')
281            ? (workId.endsWith('W') ? workId : `${workId}W`)
282            : `OL${workId}W`;
283        const data = await this._request(`/works/${id}.json`);
284        const book = this._normalizeBook(data);
285
286        if (book && book.authors.length === 0 && book.authorKeys.length > 0) {
287            book.authors = await this._resolveAuthorNames(book.authorKeys);
288        }
289
290        return book;
291    }
292
293    async getEditions(workId, options = {}) {
294        const id = workId.startsWith('OL')
295            ? (workId.endsWith('W') ? workId : `${workId}W`)
296            : `OL${workId}W`;
297        const params = new URLSearchParams({
298            limit: String(options.limit || 50),
299        });
300        const data = await this._request(`/works/${id}/editions.json?${params.toString()}`);
301        return {
302            total: data.size || 0,
303            editions: (data.entries || []).map(e => this._normalizeBook(e)),
304        };
305    }
306
307    async getAuthor(authorId) {
308        const id = authorId.startsWith('OL')
309            ? (authorId.endsWith('A') ? authorId : `${authorId}A`)
310            : `OL${authorId}A`;
311        const data = await this._request(`/authors/${id}.json`);
312
313        let bio = data.bio;
314        if (typeof bio === 'object' && bio !== null) {
315            bio = bio.value;
316        }
317
318        return {
319            key: data.key,
320            olid: data.key?.split('/').pop(),
321            name: data.name,
322            birthDate: data.birth_date || null,
323            deathDate: data.death_date || null,
324            bio,
325            wikipedia: data.wikipedia || null,
326            photos: data.photos || [],
327            links: data.links || [],
328            profileUrl: `${this.baseURL}${data.key}`,
329        };
330    }
331
332    async getAuthorWorks(authorId, options = {}) {
333        const id = authorId.startsWith('OL')
334            ? (authorId.endsWith('A') ? authorId : `${authorId}A`)
335            : `OL${authorId}A`;
336        const params = new URLSearchParams({
337            limit: String(options.limit || 50),
338            offset: String(options.offset || 0),
339        });
340        const data = await this._request(`/authors/${id}/works.json?${params.toString()}`);
341
342        const works = (data.entries || []).map(w => {
343            const authors = (w.authors || []).map(a => {
344                if (a.name) return a.name;
345                if (a.author?.name) return a.author.name;
346                return null;
347            }).filter(Boolean);
348
349            const coverId = w.cover_id || null;
350
351            return {
352                key: w.key,
353                title: w.title,
354                authors,
355                firstPublishYear: w.first_publish_year || null,
356                coverId,
357                coverUrl: coverId ? `${this.coverURL}/b/id/${coverId}-L.jpg` : null,
358                editionCount: w.edition_count || 0,
359                readUrl: `${this.baseURL}${w.key}`,
360            };
361        });
362
363        return {
364            total: data.size || 0,
365            works,
366        };
367    }
368
369    async getSubject(subject, options = {}) {
370        const params = new URLSearchParams({
371            limit: String(options.limit || 25),
372            offset: String(options.offset || 0),
373        });
374        if (options.details) params.set('details', 'true');
375
376        const cleanSubject = subject.toLowerCase().replace(/\s+/g, '_');
377        const data = await this._request(`/subjects/${cleanSubject}.json?${params.toString()}`);
378
379        return {
380            name: data.name,
381            workCount: data.work_count || 0,
382            subjectUrl: `${this.baseURL}/subjects/${cleanSubject}`,
383            books: (data.works || []).map(w => ({
384                key: w.key,
385                title: w.title,
386                authors: (w.authors || []).map(a => a.name).filter(Boolean),
387                coverId: w.cover_id || null,
388                coverUrl: w.cover_id ? `${this.coverURL}/b/id/${w.cover_id}-L.jpg` : null,
389                firstPublishYear: w.first_publish_year || null,
390                editionCount: w.edition_count || 0,
391                readUrl: `${this.baseURL}${w.key}`,
392            })),
393        };
394    }
395
396    async getSubjectList(type = 'subject') {
397        const data = await this._request(`/subjects.json?limit=100`);
398        return data.subjects || [];
399    }
400
401    async getRecentAdditions(limit = 50) {
402        const data = await this._request(`/recentadditions.json?limit=${limit}`);
403        return {
404            total: data.numFound || 0,
405            books: (data.docs || []).map(doc => this._normalizeBook(doc)),
406        };
407    }
408
409    async getTrendingBooks(timeframe = 'daily', options = {}) {
410        const validTimeframes = ['daily', 'monthly', 'yearly', 'alltime'];
411        if (!validTimeframes.includes(timeframe)) timeframe = 'daily';
412
413        const data = await this._request(`/trending/${timeframe}.json`);
414        const works = Array.isArray(data) ? data : (data.works || data.docs || []);
415
416        let books = works.map(w => {
417            const coverId = w.cover_id || null;
418            return {
419                key: w.key,
420                title: w.title,
421                authors: (w.authors || []).map(a => a.name || a.author?.name).filter(Boolean),
422                coverId,
423                coverUrl: coverId ? `${this.coverURL}/b/id/${coverId}-L.jpg` : null,
424                editionCount: w.edition_count || 0,
425                readUrl: w.key ? `${this.baseURL}${w.key}` : null,
426                borrowUrl: null,
427                canBorrow: false,
428            };
429        });
430
431        if (options.fetchDetails) {
432            console.warn(`โš ๏ธ  Fetching details untuk ${books.length} buku trending...`);
433            for (let i = 0; i < books.length; i++) {
434                const bookKey = books[i].key;
435                if (!bookKey) continue;
436
437                try {
438                    const workId = bookKey.split('/').pop();
439                    const detail = await this.getWork(workId);
440                    if (detail) {
441                        books[i].authors = detail.authors;
442                        books[i].coverId = detail.coverId;
443                        books[i].coverUrl = detail.coverUrl;
444                        books[i].borrowUrl = detail.borrowUrl;
445                        books[i].canBorrow = detail.canBorrow;
446                        books[i].iaIdentifier = detail.iaIdentifier;
447                    }
448                } catch (err) {
449                    // Abaikan error per buku
450                }
451            }
452        }
453
454        return {
455            timeframe,
456            books,
457        };
458    }
459
460    getCoverUrl(id, type = 'id', size = 'L') {
461        const validSizes = ['S', 'M', 'L'];
462        const validTypes = ['isbn', 'olid', 'oclc', 'id'];
463        if (!validSizes.includes(size)) size = 'L';
464        if (!validTypes.includes(type)) type = 'id';
465        return `${this.coverURL}/b/${type}/${id}-${size}.jpg`;
466    }
467
468    /**
469     * Dapatkan SEMUA link baca buku dari berbagai sumber
470     * Termasuk: Internet Archive, Amazon, Perpustakaan, dll
471     * @param {string} identifier - OLID (OL22597282M) atau ISBN atau IA identifier
472     * @param {string} type - "olid" | "isbn" | "oclc" | "ia"
473     * @returns {Promise<Object>} Semua link baca yang tersedia
474     */
475    async getReadLinks(identifier, type = 'olid') {
476        const links = {
477            openLibrary: null,
478            internetArchive: null,
479            preview: null,
480            external: [],
481        };
482
483        try {
484            let book = null;
485
486            if (type === 'isbn') {
487                book = await this.getByISBN(identifier);
488            } else if (type === 'oclc') {
489                book = await this.getByOCLC(identifier);
490            } else if (type === 'olid') {
491                book = await this.getByOLID(identifier);
492            } else if (type === 'ia') {
493                // Langsung IA identifier
494                links.internetArchive = `${this.archiveURL}/details/${identifier}`;
495                links.openLibrary = `${this.baseURL}/ia:${identifier}`;
496            }
497
498            if (book) {
499                links.openLibrary = book.readUrl;
500                links.internetArchive = book.borrowUrl;
501                links.preview = book.previewUrl;
502
503                if (book.raw) {
504                    // 1. Link dari field `links`
505                    if (Array.isArray(book.raw.links)) {
506                        book.raw.links.forEach(link => {
507                            links.external.push({
508                                title: link.title || 'External Link',
509                                url: link.url,
510                            });
511                        });
512                    }
513
514                    if (book.raw.identifiers) {
515                        const ids = book.raw.identifiers;
516                        if (ids.goodreads?.length > 0) {
517                            links.external.push({
518                                title: 'Goodreads',
519                                url: `https://www.goodreads.com/book/show/${ids.goodreads[0]}`,
520                            });
521                        }
522                        if (ids.librarything?.length > 0) {
523                            links.external.push({
524                                title: 'LibraryThing',
525                                url: `https://www.librarything.com/work/${ids.librarything[0]}`,
526                            });
527                        }
528                        if (ids.wikidata?.length > 0) {
529                            links.external.push({
530                                title: 'Wikidata',
531                                url: `https://www.wikidata.org/wiki/${ids.wikidata[0]}`,
532                            });
533                        }
534                        if (ids.amazon?.length > 0) {
535                            links.external.push({
536                                title: 'Amazon',
537                                url: `https://www.amazon.com/dp/${ids.amazon[0]}`,
538                            });
539                        }
540                    }
541
542                    if (book.isbn) {
543                        links.external.push({
544                            title: 'WorldCat (Perpustakaan Terdekat)',
545                            url: `https://www.worldcat.org/isbn/${book.isbn}`,
546                        });
547                        links.external.push({
548                            title: 'Google Books',
549                            url: `https://books.google.com/books?vid=ISBN${book.isbn}`,
550                        });
551                        links.external.push({
552                            title: 'Open Library (by ISBN)',
553                            url: `${this.baseURL}/isbn/${book.isbn}`,
554                        });
555                    }
556                }
557            }
558
559            const seen = new Set();
560            links.external = links.external.filter(link => {
561                if (seen.has(link.url)) return false;
562                seen.add(link.url);
563                return true;
564            });
565
566            return links;
567        } catch (error) {
568            return {
569                error: error.message,
570                openLibrary: null,
571                internetArchive: null,
572                preview: null,
573                external: [],
574            };
575        }
576    }
577
578    async getBorrowableEditions(workId) {
579        const editions = await this.getEditions(workId, { limit: 100 });
580        return editions.editions.filter(ed => ed.canBorrow && ed.borrowUrl);
581    }
582
583    async advancedSearch(filters = {}) {
584        const params = new URLSearchParams();
585
586        if (filters.query) params.set('q', filters.query);
587        if (filters.title) params.set('title', filters.title);
588        if (filters.author) params.set('author', filters.author);
589        if (filters.subject) params.set('subject', filters.subject);
590        if (filters.publisher) params.set('publisher', filters.publisher);
591        if (filters.isbn) params.set('isbn', filters.isbn);
592
593        if (filters.language) params.set('language', filters.language);
594
595        if (filters.yearFrom) {
596            params.set('first_publish_year', `${filters.yearFrom}`);
597        }
598
599        params.set('page', String(filters.page || 1));
600        params.set('limit', String(filters.limit || 25));
601
602        if (filters.sort) params.set('sort', filters.sort);
603
604        const data = await this._request(`/search.json?${params.toString()}`);
605        let books = (data.docs || []).map(doc => this._normalizeBook(doc));
606
607        if (filters.yearFrom && filters.yearTo) {
608            books = books.filter(b =>
609                b.firstPublishYear !== null &&
610                b.firstPublishYear >= filters.yearFrom &&
611                b.firstPublishYear <= filters.yearTo
612            );
613        }
614
615        return {
616            totalResults: data.numFound || 0,
617            books,
618        };
619    }
620
621    async hasISBN(isbn) {
622        try {
623            const book = await this.getByISBN(isbn);
624            return !!book;
625        } catch {
626            return false;
627        }
628    }
629
630    async batchSearch(queries, options = {}) {
631        const results = {};
632        for (const query of queries) {
633            try {
634                results[query] = await this.search(query, { limit: options.limit || 5 });
635            } catch (err) {
636                results[query] = { error: err.message };
637            }
638        }
639        return results;
640    }
641
642    /**
643     * Cek apakah buku bisa dibaca gratis di Internet Archive
644     * @param {string} isbn atau OLID
645     * @param {string} type - "isbn" | "olid"
646     * @returns {Promise<Object>}
647     */
648    async canReadOnline(identifier, type = 'isbn') {
649        const book = type === 'isbn'
650            ? await this.getByISBN(identifier)
651            : await this.getByOLID(identifier);
652
653        if (!book) {
654            return { readable: false, reason: 'Book not found' };
655        }
656
657        if (book.canBorrow && book.borrowUrl) {
658            return {
659                readable: true,
660                title: book.title,
661                borrowUrl: book.borrowUrl,
662                previewUrl: book.previewUrl,
663                readUrl: book.readUrl,
664                iaIdentifier: book.iaIdentifier,
665            };
666        }
667
668        return {
669            readable: false,
670            title: book.title,
671            readUrl: book.readUrl,
672            reason: 'Not available for online reading',
673        };
674    }
675}
676
677
678if (typeof module !== 'undefined' && module.exports) {
679    module.exports = OpenLibraryScraper;
680}
681
682const scraper = new OpenLibraryScraper({
683    userAgent: 'MyBookApp/1.0 MegawatiHamil@gmail.com', // โš ๏ธ GANTI Email Atau Biarin Pake Gmail Ini 
684    baseDelay: 1500,
685});
686
687async function OpenLibryRun() {
688    try {
689        console.log('\n๐Ÿ” SEARCH DENGAN READ URL...');
690        const search = await scraper.search('Dune Frank Herbert', { limit: 3 });
691        search.books.forEach((book, i) => {
692            console.log(`\n${i + 1}. ${book.title} (${book.firstPublishYear})`);
693            console.log(`   ๐Ÿ‘ค Penulis: ${book.authors.join(', ')}`);
694            console.log(`   ๐Ÿ“– Baca di OL: ${book.readUrl}`);
695            console.log(`   ๐Ÿ“ฅ Borrow/Read Online: ${book.borrowUrl || 'Tidak tersedia'}`);
696            console.log(`   โœ… Bisa dibaca: ${book.canBorrow ? 'Ya' : 'Tidak'}`);
697            if (book.coverUrl) console.log(`   ๐Ÿ–ผ๏ธ Cover: ${book.coverUrl}`);
698        });
699
700        console.log('\n๐Ÿ“– DETAIL BUKU (ISBN 9780441172719)...');
701        const book = await scraper.getByISBN('9780441172719');
702        if (book) {
703            console.log(`Judul: ${book.title}`);
704            console.log(`Penulis: ${book.authors.join(', ')}`);
705            console.log('\n๐Ÿ”— SEMUA URL BACA:');
706            console.log(`   ๐Ÿ“– Halaman OL: ${book.readUrl}`);
707            console.log(`   ๐Ÿ“ฅ Internet Archive: ${book.borrowUrl || 'Tidak ada'}`);
708            console.log(`   ๐Ÿ“ฅ Borrow Direct: ${book.borrowDirectUrl || 'Tidak ada'}`);
709            console.log(`   ๐Ÿ‘๏ธ Preview: ${book.previewUrl || 'Tidak ada'}`);
710            console.log(`   โœ… Can Borrow: ${book.canBorrow}`);
711            console.log(`   ๐Ÿ†” IA Identifier: ${book.iaIdentifier || 'Tidak ada'}`);
712        }
713
714        console.log('\n๐Ÿ”— ALL READ LINKS (ISBN 9780441172719)...');
715        const readLinks = await scraper.getReadLinks('9780441172719', 'isbn');
716        console.log('๐Ÿ“– Open Library:', readLinks.openLibrary);
717        console.log('๐Ÿ“ฅ Internet Archive:', readLinks.internetArchive);
718        console.log('๐Ÿ‘๏ธ Preview:', readLinks.preview);
719        console.log('๐ŸŒ External Links:');
720        readLinks.external.forEach(link => {
721            console.log(`   โ€ข ${link.title}: ${link.url}`);
722        });
723
724        console.log('\nโœ… CEK KETERBACAAN...');
725        const readable = await scraper.canReadOnline('9780441172719', 'isbn');
726        console.log(`Readability: ${JSON.stringify(readable, null, 2)}`);
727
728        console.log('\n๐Ÿ“š EDISI YANG BISA DIPINJAM (Dune)...');
729        const borrowable = await scraper.getBorrowableEditions('OL893414W');
730        console.log(`Ditemukan ${borrowable.length} edisi yang bisa dibaca online`);
731        borrowable.slice(0, 5).forEach((ed, i) => {
732            console.log(`\n${i + 1}. ${ed.title} (${ed.publishDate})`);
733            console.log(`   ๐Ÿ“ฅ Baca: ${ed.borrowUrl}`);
734        });
735
736        console.log('\n๐Ÿ“š BUKU KLASIK (biasanya bisa dibaca gratis)...');
737        const classics = await scraper.search('Pride and Prejudice Jane Austen', { limit: 1 });
738        if (classics.books[0]) {
739            const cls = classics.books[0];
740            console.log(`\n${cls.title}`);
741            console.log(`๐Ÿ“– Baca di OL: ${cls.readUrl}`);
742            console.log(`๐Ÿ“ฅ Baca di Archive: ${cls.borrowUrl || 'Tidak tersedia'}`);
743            
744            const borrowEds = await scraper.getBorrowableEditions(cls.olid);
745            if (borrowEds.length > 0) {
746                console.log(`\nโœ… ${borrowEds.length} edisi tersedia untuk dibaca:`);
747                borrowEds.slice(0, 3).forEach(ed => {
748                    console.log(`   โ€ข ${ed.title} (${ed.publishDate}) โ†’ ${ed.borrowUrl}`);
749                });
750            }
751        }
752
753    } catch (error) {
754        console.error('โŒ Error:', error.message);
755    }
756}
757
758OpenLibryRun();
758 linesยท27,983 charsยท27.4 KB