@doedja/kbbi-js
Version:
JavaScript library and CLI for the official KBBI (Kamus Besar Bahasa Indonesia) dictionary. Anonymous lookups out of the box, optional cookie auth for etymology.
174 lines (143 loc) • 5.96 kB
JavaScript
/**
* KBBI-JS Scraper
*
* Fetches an entry page and (when authenticated) follows the per-eid Details
* endpoint to fold in edit metadata. Uses native HTTPS by default. The
* Playwright fallback is loaded lazily only when `useBrowser` is requested.
*/
const chalk = require('chalk');
const KBBIParser = require('./lib/parser');
const Utils = require('./lib/utils');
const Auth = require('./lib/auth');
const { HttpClient } = require('./lib/http');
const DETAIL_CONCURRENCY = 3;
const DETAIL_DELAY_MS = 250;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
class KBBIScraper {
constructor(options = {}) {
this.options = {
headless: true,
debug: false,
timeout: 20000,
useBrowser: false,
silent: false,
...options
};
this.auth = this.options.auth || new Auth();
}
log(...args) {
if (!this.options.silent) console.log(...args);
}
async scrapeWord(word) {
if (!word) throw new Error('No word provided');
if (this.options.useBrowser) {
return this._scrapeWithBrowser(word);
}
const http = new HttpClient({ timeout: this.options.timeout });
const cookieValue = await this.auth.getRandomCookie();
if (cookieValue) {
http.jar.setRaw('.AspNet.ApplicationCookie', cookieValue);
}
this.log(chalk.bold.blue('\n=== PHASE 1: Finding Entries ==='));
const url = Utils.buildUrl(word);
this.log(`Searching for word: "${word}" at ${url}\n`);
const { ok, status, text } = await http.getText(url);
if (!ok) throw new Error(`KBBI returned HTTP ${status} for "${word}"`);
const parser = new KBBIParser(text);
const authenticated = parser.checkAuthentication();
const { entries, mirip } = parser.parseEntries();
if (entries.length === 0) {
this.log(chalk.yellow('No entries found.'));
if (mirip && mirip.length > 0) {
this.log(chalk.yellow('Mirip:'));
mirip.forEach((s) => this.log(chalk.cyan(` • ${s}`)));
}
return { word, authenticated, entries: [], mirip: mirip || [] };
}
if (!authenticated) {
this.log(chalk.gray('Skipping detail fetch: not authenticated (login adds etymology and edit metadata).'));
return { word, authenticated: false, entries, mirip: mirip || [] };
}
this.log(chalk.bold.blue('\n=== PHASE 2: Fetching Details ==='));
this.log(`Found ${entries.length} entries, fetching details...\n`);
const merged = await this._fetchDetails(http, entries, authenticated);
this.log(chalk.bold.blue('\n=== PHASE 3: Summary ==='));
this.log(`Total entries found: ${entries.length}`);
this.log(`Details fetched: ${merged.filter((e) => e.makna && e.makna.length > 0).length}`);
this.log(`Authentication status: ${chalk.green('Yes')}`);
return { word, authenticated: true, entries: merged, mirip: mirip || [] };
}
async _fetchDetails(http, entries, authenticated) {
const out = new Array(entries.length);
let cursor = 0;
const worker = async () => {
while (cursor < entries.length) {
const idx = cursor++;
const entry = entries[idx];
if (!entry || !entry.id) {
out[idx] = entry;
continue;
}
const detailsUrl = Utils.buildDetailsUrl(entry.id);
this.log(`Fetching details for "${entry.nama}${entry.nomor ? ' ' + entry.nomor : ''}" (ID: ${entry.id})`);
const { ok, status, text } = await http.getText(detailsUrl);
if (!ok) {
this.log(chalk.red(`Details HTTP ${status} for ${entry.id}`));
out[idx] = entry;
continue;
}
const detailsParser = new KBBIParser(text, authenticated);
const details = detailsParser.parseDetailsPage();
out[idx] = { ...entry, ...details };
await sleep(DETAIL_DELAY_MS);
}
};
const workers = Array.from({ length: Math.min(DETAIL_CONCURRENCY, entries.length) }, worker);
await Promise.all(workers);
return out;
}
async _scrapeWithBrowser(word) {
const BrowserManager = require('./lib/browser');
const url = Utils.buildUrl(word);
this.log(chalk.bold.blue('\n=== PHASE 1: Finding Entries (browser) ==='));
const browser = new BrowserManager({
headless: this.options.headless,
debug: this.options.debug,
timeout: this.options.timeout
});
let entries = [];
let mirip = [];
let authenticated = false;
try {
await browser.initBrowser();
const cookieString = await this.auth.getCookieString();
if (cookieString) await browser.setCustomCookie(cookieString);
const html = await browser.navigateTo(url);
if (!html) throw new Error('Failed to fetch page content');
const isCloudflare = await browser.checkCloudflare();
if (isCloudflare && !this.options.headless) {
await browser.page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 45000 });
} else if (isCloudflare) {
throw new Error('Cloudflare challenge detected in headless mode');
}
const parser = new KBBIParser(html);
authenticated = parser.checkAuthentication();
const parsed = parser.parseEntries();
entries = parsed.entries;
mirip = parsed.mirip || [];
} finally {
await browser.close();
}
if (entries.length === 0 || !authenticated) {
return { word, authenticated, entries, mirip };
}
// Authenticated browser callers may want details. Reuse the fetch path
// with their cookie pool instead of spinning a second Chromium per eid.
const http = new HttpClient({ timeout: this.options.timeout });
const cookieValue = await this.auth.getRandomCookie();
if (cookieValue) http.jar.setRaw('.AspNet.ApplicationCookie', cookieValue);
const merged = await this._fetchDetails(http, entries, authenticated);
return { word, authenticated, entries: merged, mirip };
}
}
module.exports = { KBBIScraper };