@doedja/kbbi-js
Version:
JavaScript library for accessing KBBI (Kamus Besar Bahasa Indonesia) with anti-Cloudflare protection
1,028 lines (880 loc) • 32.3 kB
JavaScript
const cheerio = require('cheerio');
const { NotFoundError } = require('./errors');
/**
* Parser for KBBI website
* Supports both direct extraction and detailed scraping
*/
class KBBIParser {
constructor(html, isAuthenticated = false) {
this.$ = cheerio.load(html);
this.isAuthenticated = isAuthenticated;
}
/**
* Parse entries from search result page
* @returns {Object} Object containing entries and mirip
*/
parseEntries() {
const entries = [];
const entryHeadings = this.$('h2[style*="margin-bottom:3px"]');
if (entryHeadings.length === 0) {
const notFoundMsg = this.$('div:contains("Entri tidak ditemukan")');
if (notFoundMsg.length > 0) {
return { entries: [], mirip: this.parseMirip() };
}
}
// Handle details page detection
const isDetailsPage = this.$('.page-header h2:contains("Detail Data")').length > 0;
if (isDetailsPage) {
const entry = this.parseDetailsPage();
if (entry) {
entries.push(entry);
}
return { entries };
}
// Process entries from search results page
entryHeadings.each((index, h2) => {
const entry = this.parseEntryDirectly(this.$(h2));
if (entry) {
entries.push(entry);
}
});
return { entries };
}
/**
* Parse entry directly from search results page
* @param {Object} $h2 - jQuery object of h2 element
* @returns {Object|null} Entry object or null if invalid
*/
parseEntryDirectly($h2) {
if (!$h2 || !$h2.length) return null;
// Extract basic entry information
const entryName = this.extractText($h2).trim();
if (!entryName) return null;
// Extract entry ID
const entryId = this.extractId($h2);
// Extract homonym number (superscript)
const homonymNumber = this.extractNumber($h2);
// Create entry object
const entry = {
id: entryId,
nama: entryName.replace(/\s*\u00B9|\u00B2|\u00B3|\u2074|\u2075|\u2076|\u2077|\u2078|\u2079|\u2070/g, ''), // Remove superscripts from name
nomor: homonymNumber,
makna: []
};
// Extract entry type if available (peribahasa, idiom, etc.)
const entryType = this.extractEntryType($h2);
if (entryType) {
entry.jenis = entryType;
}
// Extract root word if available
const rootWord = this.extractRootWord($h2);
if (rootWord) {
entry.rootWord = rootWord;
}
// Find the list of meanings for this entry
const $meaningsList = $h2.nextUntil('h2, h4', 'ol, ul').first();
if ($meaningsList.length) {
// Process each list item as a meaning
$meaningsList.children('li').each((i, li) => {
const $li = this.$(li);
// Skip "Usulkan makna baru" items
if ($li.text().includes('Usulkan makna baru')) return;
// Extract meaning number (if available)
const meaningNumber = i + 1;
// Extract word classes
const wordClasses = this.extractWordClasses($li);
// Extract definition
const definition = this.extractDefinition($li);
// Extract examples
const examples = this.extractExamples($li);
// Add meaning to entry
if (definition) {
entry.makna.push({
nomor: meaningNumber.toString(),
kelasKata: wordClasses,
definisi: definition,
contoh: examples
});
}
});
} else {
// Try alternative extraction for compound words or special entries
const meanings = this.extractMeaningsDirectly($h2);
if (meanings && meanings.length) {
entry.makna = meanings;
}
}
// Extract etymology if available
const etymology = this.extractEtymology($h2);
if (etymology) {
entry.etimologi = etymology;
}
// Extract related words
const related = this.extractRelated($h2);
if (related) {
entry.terkait = related;
}
return entry;
}
/**
* Parse entry for scraping (basic information only)
* @param {Object} $h2 - jQuery object of the h2 element
* @returns {Object} Entry object with basic identification info
*/
parseEntry($h2) {
// For scraping, we only need the basic ID information
const nama = this.extractText($h2);
const nomor = this.extractNumber($h2);
const id = this.extractId($h2);
const rootWord = this.extractRootWord($h2);
if (!id) {
return null;
}
return { nama, nomor, id, rootWord };
}
/**
* Parse a details page
* @returns {Object} Entry object with detailed information
*/
parseDetailsPage() {
const result = {};
// Extract basic information from rows
this.$('.row').each((i, row) => {
const $row = this.$(row);
const label = $row.find('.col-md-2 b').text().trim();
const value = $row.find('.col-md-10').text().trim();
// Skip empty or "Tidak tersedia" values
if (!value || value === '(Tidak tersedia)') return;
// Map common fields
switch (label) {
case 'Eid': result.id = value; break;
case 'Entri': result.nama = value; break;
case 'Id Homonim': result.nomor = value; break;
case 'Jenis Entri': result.jenis = value; break;
case 'Induk Kata':
// Process parent word/root word if available
if (value) {
const match = value.match(/(.+?)\s*\(Eid:\s*(\d+)\)/);
if (match) {
result.rootWord = match[1].trim();
} else {
result.rootWord = value;
}
}
break;
case 'Makna':
if (!result.makna) result.makna = [];
result.makna.push({
definisi: value,
kelasKata: [],
contoh: []
});
break;
case 'Kelas Kata':
if (result.makna && result.makna.length > 0) {
const lastMakna = result.makna[result.makna.length - 1];
const match = value.match(/^(\w+)\s+\((.*?)\)$/);
if (match) {
lastMakna.kelasKata = [{
kode: match[1],
nama: match[2]
}];
} else {
// Handle multiple word classes
const classes = value.split(',').map(cls => {
const parts = cls.trim().match(/^(\w+)\s+\((.*?)\)$/);
if (parts) {
return { kode: parts[1], nama: parts[2] };
}
return { kode: cls.trim(), nama: '' };
});
lastMakna.kelasKata = classes;
}
}
break;
case 'Contoh':
// Sometimes the details page directly contains examples in rows
if (result.makna && result.makna.length > 0) {
const lastMakna = result.makna[result.makna.length - 1];
if (!lastMakna.contoh) lastMakna.contoh = [];
lastMakna.contoh.push({
nomor: lastMakna.contoh.length + 1,
teks: value
});
}
break;
}
});
// Find all example headers and extract the actual examples
this.$('h4').filter((_, h4) => {
return this.$(h4).text().trim().startsWith('Contoh #');
}).each((_, h4) => {
const match = this.$(h4).text().match(/Contoh #(\d+)-(\d+)/);
if (!match) return;
const meaningNum = parseInt(match[1]);
const exampleNum = parseInt(match[2]);
if (!result.makna || meaningNum > result.makna.length) return;
const meaning = result.makna[meaningNum - 1];
if (!meaning.contoh) meaning.contoh = [];
// Find the row containing the example text
const $rows = this.$(h4).nextUntil('h4', '.row');
let exampleText = '';
$rows.each((_, row) => {
const $row = this.$(row);
const label = $row.find('.col-md-2 b').text().trim();
if (label === 'Contoh') {
exampleText = $row.find('.col-md-10').text().trim();
// Stop the loop after finding the example
return false;
}
});
// Only add if we found actual text
if (exampleText && exampleText !== '(Tidak tersedia)') {
// Replace example with same number if it exists, otherwise add new
const existingIndex = meaning.contoh.findIndex(ex => ex.nomor === exampleNum);
const cleanedText = exampleText.replace(/\[(.*?)\]/g, '$1').trim();
if (existingIndex >= 0) {
meaning.contoh[existingIndex].teks = cleanedText;
} else {
meaning.contoh.push({
nomor: exampleNum,
teks: cleanedText
});
}
}
});
// Try to find root word in title if not already found
if (!result.rootWord) {
const $title = this.$('.page-header h2');
if ($title.length) {
const $rootWord = $title.find('.rootword a');
if ($rootWord.length) {
let rootText = $rootWord.text().trim();
const $sup = $rootWord.find('sup');
if ($sup.length) {
rootText += this.toSuperscript($sup.text().trim());
}
result.rootWord = rootText;
}
}
}
// Handle compound words specially - check if the name has a space in it
if (result.nama && result.nama.includes(' ')) {
// If we don't have any meanings yet, or they're incomplete, try specialized handling
if (!result.makna || result.makna.length === 0 ||
(result.makna.length > 0 && result.makna.every(m => !m.definisi || m.definisi === '→'))) {
// Try to extract meanings using compound word specific logic
const compoundMeanings = this.extractCompoundWordMeanings(this.$('h2').first());
if (compoundMeanings && compoundMeanings.length > 0) {
result.makna = compoundMeanings;
}
}
}
return Object.keys(result).length > 0 ? result : null;
}
/**
* Extract text directly from an element
* @param {Object} $element - jQuery element
* @returns {string} Extracted text
*/
extractText($element) {
// If element has an italic tag, use that for the name
const $italic = $element.find('i');
if ($italic.length) {
return $italic.text().trim();
}
// Otherwise get the text directly, removing any child elements
return $element.clone().children().remove().end().text().trim();
}
/**
* Extract homonym number
* @param {Object} $h2 - jQuery object
* @returns {string} Number as string
*/
extractNumber($h2) {
const $sup = $h2.find('sup');
return $sup.length ? $sup.text().trim() : '';
}
/**
* Extract entry ID
* @param {Object} $h2 - jQuery object
* @returns {string} Entry ID
*/
extractId($h2) {
// First try finding in edit links
const $editLinks = $h2.find('a[href*="Edit"]');
if ($editLinks.length) {
const href = $editLinks.attr('href');
const match = href.match(/eid=(\d+)/);
if (match) return match[1];
}
// Then try in view links
const $viewLinks = $h2.find('a[href*="View"]');
if ($viewLinks.length) {
const href = $viewLinks.attr('href');
const match = href.match(/\/(\d+)/);
if (match) return match[1];
}
// Try parent container links
const $parentLinks = $h2.parent().find('a[href*="entri"]');
if ($parentLinks.length) {
const href = $parentLinks.attr('href');
const match = href.match(/\/(\d+)/);
if (match) return match[1];
}
return null;
}
/**
* Extract meanings directly from the search results page
* @param {Object} $h2 - jQuery object of h2 element
* @returns {Array} Array of meaning objects
*/
extractMeaningsDirectly($h2) {
const meanings = [];
// Find the list elements that contain meanings
// First try ordered list (ol) which is the standard format
let $list = $h2.nextUntil('h2, h4', 'ol').first();
// If no ordered list, try unordered list (ul) which is used for some entries
if (!$list.length) {
$list = $h2.nextUntil('h2, h4', 'ul').first();
}
// If we found a list, process each list item
if ($list.length) {
$list.children('li').each((i, li) => {
const $li = this.$(li);
// Skip "Usulkan makna baru" items
if ($li.text().includes('Usulkan makna baru')) return;
// Extract meaning number (if available)
const meaningNumber = i + 1;
// Extract word classes
const wordClasses = this.extractWordClasses($li);
// Extract definition
const definition = this.extractDefinition($li);
// Extract examples
const examples = this.extractExamples($li);
// Add meaning to array
if (definition) {
meanings.push({
nomor: meaningNumber.toString(),
kelasKata: wordClasses,
definisi: definition,
contoh: examples
});
}
});
} else {
// For entries that don't use a list format, try to extract from the text
// This is common for some special entries like abbreviations
// Look for text after the heading but before the next heading
const $content = $h2.nextUntil('h2, h4');
if ($content.length) {
// Extract text content
let text = '';
$content.each((i, el) => {
// Skip certain elements
if (this.$(el).is('h4') || this.$(el).is('br')) return;
// Add text content
text += this.$(el).text().trim() + ' ';
});
// Clean up text
text = text.trim();
if (text && !text.includes('Usulkan makna baru')) {
// Extract word classes from the text
const wordClasses = [];
// Look for common word class patterns
const classMatch = text.match(/\b(n|v|a|adv|num|p|pron)\b/);
if (classMatch) {
const code = classMatch[1];
let name = '';
// Map common codes to names
switch (code) {
case 'n': name = 'Nomina'; break;
case 'v': name = 'Verba'; break;
case 'a': name = 'Adjektiva'; break;
case 'adv': name = 'Adverbia'; break;
case 'num': name = 'Numeralia'; break;
case 'p': name = 'Partikel'; break;
case 'pron': name = 'Pronomina'; break;
}
if (name) {
wordClasses.push({
kode: code,
nama: name
});
}
// Remove the word class from the text
text = text.replace(classMatch[0], '').trim();
}
// Add as a single meaning
meanings.push({
nomor: '1',
kelasKata: wordClasses,
definisi: text,
contoh: []
});
}
}
}
return meanings;
}
/**
* Special extraction for compound words which often have a different structure
* @param {Object} $h2 - jQuery object of entry heading
* @returns {Array} Array of meaning objects
*/
extractCompoundWordMeanings($h2) {
const meanings = [];
// For compound words, the structure is often:
// 1. A heading (h2)
// 2. Followed by a list (ol) with items (li) containing definitions
// Start by finding the closest list
let $ol = $h2.nextAll('ol').first();
// If not found directly after, try the parent container
if (!$ol.length) {
const $container = $h2.parent();
$ol = $container.find('ol').first();
}
// If we found a list, process the items
if ($ol.length) {
$ol.find('li').each((i, li) => {
const $li = this.$(li);
const text = $li.text().trim();
// Skip "usulkan makna baru" items
if (text.includes('Usulkan makna baru')) {
return;
}
// Extract word classes, definition, and examples
const wordClasses = this.extractWordClasses($li);
let definitionText = text;
// Remove word class markers
$li.find('[color="red"], font[color="red"]').each((_, el) => {
definitionText = definitionText.replace(this.$(el).text(), '');
});
// Clean up the definition
definitionText = definitionText.replace(/^\s*[;:]\s*|\s*[;:]\s*$/, '').trim();
// Check if there are examples (separated by colon)
const examples = [];
const colonIndex = definitionText.indexOf(':');
if (colonIndex !== -1) {
const examplesText = definitionText.substring(colonIndex + 1).trim();
definitionText = definitionText.substring(0, colonIndex).trim();
if (examplesText) {
// Split by semicolons to get multiple examples
examplesText.split(';').forEach((ex, index) => {
const exampleText = ex.trim();
if (exampleText) {
examples.push({
nomor: index + 1,
teks: exampleText
});
}
});
}
}
// Create the meaning object
if (definitionText) {
meanings.push({
nomor: i + 1,
kelasKata: wordClasses,
definisi: definitionText,
contoh: examples,
kiasan: $li.find('i:contains("ki")').length > 0
});
}
});
}
// If no meanings found, look for paragraph-style definitions
if (meanings.length === 0) {
// Try to find paragraph elements following the heading
let found = false;
const $container = $h2.parent().parent(); // Go up two levels to find the container
// Find paragraphs or divs with text inside the container
$container.find('p, div').each((i, el) => {
// Skip if we already passed another heading
if (this.$(el).prevAll('h2').first().get(0) !== $h2.get(0)) {
return;
}
const text = this.$(el).text().trim();
if (text && text.length > 5 && !text.includes('Usulkan makna baru')) {
meanings.push({
nomor: meanings.length + 1,
kelasKata: [],
definisi: text,
contoh: [],
kiasan: false
});
found = true;
}
});
// If still no definitions found, try direct text nodes
if (!found) {
let nextNode = $h2.get(0).nextSibling;
while (nextNode) {
if (nextNode.nodeType === 3) { // Text node
const text = nextNode.nodeValue.trim();
if (text && text.length > 5) {
meanings.push({
nomor: meanings.length + 1,
kelasKata: [],
definisi: text,
contoh: [],
kiasan: false
});
found = true;
}
} else if (nextNode.nodeType === 1 && nextNode.tagName.toLowerCase() === 'h2') {
// Stop if we reach another h2
break;
}
nextNode = nextNode.nextSibling;
}
}
}
return meanings;
}
/**
* Extract word classes
* @param {Object} $li - jQuery object
* @returns {Array} Array of word class objects
*/
extractWordClasses($li) {
const classes = [];
// Exclusively look for red-colored elements which contain word classes
// This avoids misinterpreting parts of the definition as word classes
// Method 1: Extract from font color="red" elements with span title attributes
$li.find('font[color="red"] span[title], [color="red"] span[title]').each((i, span) => {
const $span = this.$(span);
const code = $span.text().trim();
const title = $span.attr('title');
if (code && title) {
const parts = title.split(':');
const name = parts[0]?.trim() || '';
classes.push({
kode: code,
nama: name
});
}
});
// Method 2: Extract directly from font[color="red"] elements
if (classes.length === 0) {
// Find all red-colored font elements
$li.find('font[color="red"], [color="red"]').each((i, font) => {
const $font = this.$(font);
// Skip if this is inside another element we've already processed
if ($font.parents('font[color="red"], [color="red"]').length > 0) return;
// Get the text content
const fontText = $font.text().trim();
if (fontText) {
// Split by spaces to get individual class codes
const parts = fontText.split(/\s+/);
parts.forEach(part => {
const cleanPart = part.trim();
if (cleanPart && cleanPart !== ',' && cleanPart !== ';' && cleanPart !== '(' && cleanPart !== ')') {
let name = '';
// Find any span with title inside this element
const $titleSpan = $font.find('span[title]');
if ($titleSpan.length > 0) {
const title = $titleSpan.attr('title');
if (title) {
const titleParts = title.split(':');
name = titleParts[0]?.trim() || '';
}
} else {
// Map common codes to names if no title is available
switch (cleanPart) {
case 'n': name = 'Nomina'; break;
case 'v': name = 'Verba'; break;
case 'a': name = 'Adjektiva'; break;
case 'adv': name = 'Adverbia'; break;
case 'num': name = 'Numeralia'; break;
case 'p': name = 'Partikel'; break;
case 'pron': name = 'Pronomina'; break;
case 'ki': name = 'kiasan'; break;
case 'Jp': name = 'Jepang'; break;
case 'sing': name = 'singkatan'; break;
case 'Komp': name = 'Komputer'; break;
case 'Prw': name = 'Pariwisata'; break;
case 'Kap': name = 'Perkapalan'; break;
default: name = ''; break;
}
}
// Only add if we have a valid code
if (cleanPart) {
classes.push({
kode: cleanPart,
nama: name
});
}
}
});
}
});
}
return classes;
}
/**
* Map language or special codes to descriptive names
* @param {string} code - Language or special code
* @returns {string} Descriptive name
*/
mapLanguageCode(code) {
const map = {
'Jp': 'Jepang',
'Cn': 'Cina',
'Ar': 'Arab',
'Skt': 'Sanskerta',
'Jw': 'Jawa',
'Sd': 'Sunda',
'Eng': 'Inggris',
'kl': 'Klasik',
'ki': 'Kiasan',
'cak': 'Cakapan'
};
return map[code] || code;
}
/**
* Extract definition from li element
* @param {Object} $li - jQuery object
* @returns {string} Definition text
*/
extractDefinition($li) {
// Clone the element to avoid modifying the original
const $clone = $li.clone();
// Remove elements we don't want in the definition
$clone.find('font[color="grey"], [color="grey"]').remove();
$clone.find('font[color="red"], [color="red"]').remove();
$clone.find('.entrisButton').remove();
// Get the text content of what remains
let definition = $clone.text().trim();
// Clean up the definition
definition = definition.replace(/\s+/g, ' '); // Replace multiple spaces with single space
definition = definition.replace(/^\s*:\s*/, ''); // Remove leading colon
definition = definition.replace(/\s*[;:]\s*$/, ''); // Remove trailing semicolon or colon
// Remove unwanted characters
definition = definition.replace(/^[-–—•\s]+/, '');
return definition || 'Tidak tersedia';
}
/**
* Extract examples
* @param {Object} $li - jQuery object
* @returns {Array} Array of example objects
*/
extractExamples($li) {
const examples = [];
// Look for grey font elements which typically contain examples
const $exampleElements = $li.find('font[color="grey"]');
if ($exampleElements.length) {
$exampleElements.each((i, el) => {
const text = this.$(el).text().trim();
if (text) {
examples.push({
nomor: i + 1,
teks: text
});
}
});
} else {
// If no specific example elements, try to find examples after a colon
const text = $li.text().trim();
const colonIndex = text.indexOf(':');
if (colonIndex !== -1) {
const examplesText = text.substring(colonIndex + 1).trim();
if (examplesText) {
// Split by semicolons for multiple examples
examplesText.split(';').forEach((ex, index) => {
const exampleText = ex.trim();
if (exampleText) {
examples.push({
nomor: index + 1,
teks: exampleText
});
}
});
}
}
}
return examples;
}
/**
* Extract etymology information
* @param {Object} $h2 - jQuery object
* @returns {Object|null} Etymology object or null
*/
extractEtymology($h2) {
// Simplified approach: directly search for the etymology pattern in the HTML
const htmlContent = this.$.html();
// Look for the typical etymology pattern: label followed by bracketed content
const etymologyRegex = /Etimologi:<\/b>\s*\[(.*?)\]/i;
const match = htmlContent.match(etymologyRegex);
if (match && match[1]) {
const etymologyContent = match[1].trim();
// Extract language name - typically in <i> tags with darkred color
const langRegex = /<i[^>]*color:darkred[^>]*>(.*?)<\/i>/i;
const langMatch = etymologyContent.match(langRegex);
const languages = [];
if (langMatch && langMatch[1]) {
languages.push(langMatch[1].trim());
}
// Clean up the etymology text by removing HTML tags
const cleanText = etymologyContent
.replace(/<[^>]*>/g, ' ') // Replace HTML tags with spaces
.replace(/\s+/g, ' ') // Normalize whitespace
.trim();
return {
text: `[${cleanText}]`,
languages
};
}
return null;
}
/**
* Extract related words
* @param {Object} $h2 - jQuery object
* @returns {Object|null} Related words object or null
*/
extractRelated($h2) {
const related = {
kataTurunan: [],
gabunganKata: [],
peribahasa: [],
idiom: []
};
// Find all section headings (h4) that follow the main entry heading
const $sections = $h2.nextAll('h4');
$sections.each((_, section) => {
const $section = this.$(section);
const headingText = $section.text().trim();
// Based on the heading text, determine which type of related words this is
let relatedType = null;
if (headingText.includes('Kata Turunan')) {
relatedType = 'kataTurunan';
} else if (headingText.includes('Gabungan Kata')) {
relatedType = 'gabunganKata';
} else if (headingText.includes('Peribahasa')) {
relatedType = 'peribahasa';
} else if (headingText.includes('Idiom')) {
relatedType = 'idiom';
}
if (relatedType) {
// Find the element containing the related words (usually a ul following the heading)
const $container = $section.next('ul');
if ($container.length) {
// Extract links within the container
$container.find('a').each((_, link) => {
const text = this.$(link).text().trim();
if (text && !related[relatedType].includes(text)) {
related[relatedType].push(text);
}
});
}
}
});
// Only return if we found any related words
return (
related.kataTurunan.length ||
related.gabunganKata.length ||
related.peribahasa.length ||
related.idiom.length
) ? related : null;
}
/**
* Parse mirip when word is not found
* @returns {Array} Array of mirip strings
*/
parseMirip() {
const mirip = [];
// Look for mirip in the standard location
this.$('.col-md-3').each((i, el) => {
const text = this.$(el).text().trim();
if (text) {
mirip.push(text);
}
});
// Also check alternate locations
this.$('ul.daftar-selaras li a').each((i, el) => {
const text = this.$(el).text().trim();
if (text) {
mirip.push(text);
}
});
return mirip;
}
/**
* Check if user is authenticated
* @returns {boolean} Authentication status
*/
checkAuthentication() {
// Look for login/logout indicators
return (
this.$('a:contains("Keluar")').length > 0 ||
this.$('button:contains("Keluar")').length > 0
);
}
/**
* Extract root word from entry heading
* @param {Object} $h2 - jQuery object of h2 element
* @returns {string|null} Root word text or null if not present
*/
extractRootWord($h2) {
// Look for root word in different formats
// Method 1: Using ul elements on the page with links containing the root word
const rootWords = [];
// Check parent page structure for kata dasar (root word) section
this.$('h4:contains("Kata Dasar")').next('ul').find('a').each((i, link) => {
const rootText = this.$(link).text().trim();
if (rootText) rootWords.push(rootText);
});
// Method 2: Look for rootword span or links in the heading
if (rootWords.length === 0) {
const $rootWord = $h2.find('.rootword');
if ($rootWord.length) {
// Extract text from the link inside rootword span
const $link = $rootWord.find('a');
if ($link.length) {
let rootText = $link.text().trim();
// If there's a superscript, include it
const $sup = $link.find('sup');
if ($sup.length) {
const supText = $sup.text().trim();
if (supText) {
rootText += this.toSuperscript(supText);
}
}
// Remove any stray numbers that might appear before the superscript (e.g., "kaki1¹")
rootText = rootText.replace(/(\w+)\d+([\u00B9\u00B2\u00B3\u2074-\u207E]+)/, '$1$2');
rootWords.push(rootText);
}
}
}
return rootWords.length > 0 ? rootWords[0] : null;
}
/**
* Convert a number to superscript for display
* @param {string} number - Number to convert
* @returns {string} Superscript version of the number
*/
toSuperscript(number) {
if (!number) return '';
const superscripts = {
'0': '⁰', '1': '¹', '2': '²', '3': '³', '4': '⁴',
'5': '⁵', '6': '⁶', '7': '⁷', '8': '⁸', '9': '⁹'
};
return number.toString().split('').map(char => superscripts[char] || char).join('');
}
/**
* Extract entry type from small tag following the heading
* @param {Object} $h2 - jQuery object of h2 element
* @returns {string|null} Entry type or null if not found
*/
extractEntryType($h2) {
// Look for the small tag with saddlebrown color that follows the h2 heading
const $entryType = $h2.nextAll('small[style*="color:saddlebrown"]').first();
if ($entryType.length) {
// Extract the text content and remove any HTML tags
const text = $entryType.text().replace(/<[^>]*>/g, '').trim();
return text;
}
return null;
}
}
module.exports = KBBIParser;