Back to Home
Gistify Snippet
Public
MyAnimeList (MAL) Top & Search Scraper
High-performance MyAnimeList scraper using native fetch for top anime rankings, seasonal charts, and anime search with clean thumbnails.
#scraper#anime#myanimelist#search#javascript
0
Rawmyanimelist.jsjavascript
355 lines| 1 | // myanimelist.js — bundle standalone. npm deps: cheerio, node:url |
| 2 | |
| 3 | /** |
| 4 | * Scraper: myanimelist.js |
| 5 | * Source : kyioapi/myanimelist.js |
| 6 | * |
| 7 | * Input : Function parameters / CLI args (e.g. query: string, url: string, options: object) |
| 8 | * Output : Promise<Object | Array | Buffer> (JSON formatted scraping response / stream URL / media metadata) |
| 9 | * |
| 10 | * CLI Run: |
| 11 | * node kyioapi/myanimelist.js [functionName] [args...] |
| 12 | * node cli.js kyioapi/myanimelist.js [functionName] [args...] |
| 13 | */ |
| 14 | |
| 15 | import * as __ns_241 from 'cheerio' |
| 16 | const cheerio = __ns_241 |
| 17 | import * as __ns_242 from 'node:url' |
| 18 | const pathToFileURL = __ns_242.pathToFileURL |
| 19 | |
| 20 | |
| 21 | |
| 22 | /** |
| 23 | * Base Scraper Class |
| 24 | * Centralizes request logic, headers, and common scraping utilities. |
| 25 | * Refactored to use native fetch for better performance in Node.js 18+. |
| 26 | */ |
| 27 | class BaseScraper { |
| 28 | constructor(baseURL, options = {}) { |
| 29 | this.baseURL = baseURL; |
| 30 | this.options = { |
| 31 | timeout: options.timeout || 30000, |
| 32 | headers: { |
| 33 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', |
| 34 | 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', |
| 35 | 'Accept-Language': 'en-US,en;q=0.9,id;q=0.8', |
| 36 | ...options.headers |
| 37 | }, |
| 38 | withCookies: options.withCookies || false |
| 39 | }; |
| 40 | this.cookies = new Map(); |
| 41 | } |
| 42 | |
| 43 | /** |
| 44 | * Internal fetch wrapper with timeout and cookie handling |
| 45 | */ |
| 46 | async _request(url, config = {}) { |
| 47 | const fullUrl = url.startsWith('http') ? url : `${this.baseURL}${url}`; |
| 48 | const method = config.method || 'GET'; |
| 49 | const headers = { ...this.options.headers, ...config.headers }; |
| 50 | |
| 51 | if (this.options.withCookies && this.cookies.size > 0) { |
| 52 | headers['cookie'] = Array.from(this.cookies.entries()) |
| 53 | .map(([k, v]) => `${k}=${v}`) |
| 54 | .join('; '); |
| 55 | } |
| 56 | |
| 57 | const controller = new AbortController(); |
| 58 | const id = setTimeout(() => controller.abort(), config.timeout || this.options.timeout); |
| 59 | |
| 60 | try { |
| 61 | const response = await fetch(fullUrl, { |
| 62 | method, |
| 63 | headers, |
| 64 | body: config.data ? (typeof config.data === 'object' ? JSON.stringify(config.data) : config.data) : undefined, |
| 65 | signal: controller.signal, |
| 66 | ...config.fetchOptions |
| 67 | }); |
| 68 | |
| 69 | clearTimeout(id); |
| 70 | |
| 71 | if (this.options.withCookies) { |
| 72 | const setCookie = response.headers.get('set-cookie'); |
| 73 | if (setCookie) { |
| 74 | setCookie.split(',').forEach(cookie => { |
| 75 | const [pair] = cookie.split(';'); |
| 76 | const [key, value] = pair.split('='); |
| 77 | if (key && value) this.cookies.set(key.trim(), value.trim()); |
| 78 | }); |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | return response; |
| 83 | } catch (error) { |
| 84 | clearTimeout(id); |
| 85 | throw error; |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | /** |
| 90 | * Fetches raw text |
| 91 | */ |
| 92 | async getText(url, config = {}) { |
| 93 | try { |
| 94 | const response = await this._request(url, config); |
| 95 | return await response.text(); |
| 96 | } catch (error) { |
| 97 | console.error(`Scraper Text Error [GET ${url}]:`, error.message); |
| 98 | throw error; |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | /** |
| 103 | * Fetches HTML and returns a Cheerio object |
| 104 | */ |
| 105 | async getHTML(url, config = {}) { |
| 106 | try { |
| 107 | const data = await this.getText(url, config); |
| 108 | return cheerio.load(data); |
| 109 | } catch (error) { |
| 110 | throw error; |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | /** |
| 115 | * Fetches JSON data |
| 116 | */ |
| 117 | async getJSON(url, config = {}) { |
| 118 | try { |
| 119 | const response = await this._request(url, config); |
| 120 | const text = await response.text(); |
| 121 | try { |
| 122 | return JSON.parse(text); |
| 123 | } catch (e) { |
| 124 | if (text.trim().startsWith('<!DOCTYPE') || text.trim().startsWith('<html')) { |
| 125 | throw new Error(`Expected JSON but received HTML. This might be a Cloudflare challenge or an error page. [GET ${url}]`); |
| 126 | } |
| 127 | throw new Error(`Failed to parse JSON response: ${e.message}. [GET ${url}]`); |
| 128 | } |
| 129 | } catch (error) { |
| 130 | console.error(`Scraper JSON Error [GET ${url}]:`, error.message); |
| 131 | throw error; |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | /** |
| 136 | * Post JSON data |
| 137 | */ |
| 138 | async postJSON(url, body, config = {}) { |
| 139 | try { |
| 140 | const response = await this._request(url, { |
| 141 | method: 'POST', |
| 142 | headers: { 'Content-Type': 'application/json', ...config.headers }, |
| 143 | data: body, |
| 144 | ...config |
| 145 | }); |
| 146 | const text = await response.text(); |
| 147 | try { |
| 148 | return JSON.parse(text); |
| 149 | } catch (e) { |
| 150 | if (text.trim().startsWith('<!DOCTYPE') || text.trim().startsWith('<html')) { |
| 151 | throw new Error(`Expected JSON but received HTML. This might be a Cloudflare challenge or an error page. [POST ${url}]`); |
| 152 | } |
| 153 | if (config.allowTextResponse) return text; |
| 154 | throw new Error(`Failed to parse JSON response: ${e.message}. [POST ${url}]`); |
| 155 | } |
| 156 | } catch (error) { |
| 157 | console.error(`Scraper POST Error [POST ${url}]:`, error.message); |
| 158 | throw error; |
| 159 | } |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | |
| 164 | |
| 165 | |
| 166 | /** |
| 167 | * MyAnimeList (MAL) Scraper |
| 168 | * Handles searching, top charts, and details. |
| 169 | */ |
| 170 | class MyAnimeList extends BaseScraper { |
| 171 | constructor(options = {}) { |
| 172 | super('https://myanimelist.net', { |
| 173 | ...options, |
| 174 | headers: { |
| 175 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36', |
| 176 | ...options.headers |
| 177 | } |
| 178 | }); |
| 179 | } |
| 180 | |
| 181 | /** |
| 182 | * Clean image URL to get full resolution |
| 183 | */ |
| 184 | _formatImage(url) { |
| 185 | if (!url) return ''; |
| 186 | // Using string replace to avoid regex slash confusion in some environments |
| 187 | return url.split('/r/')[0].split('?')[0]; |
| 188 | } |
| 189 | |
| 190 | /** |
| 191 | * Search anime |
| 192 | */ |
| 193 | async search(query) { |
| 194 | try { |
| 195 | if (!query) throw new Error('Query is required'); |
| 196 | const $ = await this.getHTML(`/anime.php?q=${encodeURIComponent(query)}`); |
| 197 | const results = []; |
| 198 | |
| 199 | const rows = $(".js-categories-seasonal table tr").length > 0 |
| 200 | ? $(".js-categories-seasonal table tr").slice(1) |
| 201 | : $(".list table tr").slice(1); |
| 202 | |
| 203 | rows.each((i, el) => { |
| 204 | const row = $(el); |
| 205 | const titleAnchor = row.find("a.hoverinfo_trigger").first(); |
| 206 | const title = titleAnchor.find("strong").text().trim() || row.find(".title strong").text().trim(); |
| 207 | const link = titleAnchor.attr("href") || row.find(".title a").attr("href"); |
| 208 | |
| 209 | if (!title || !link) return; |
| 210 | |
| 211 | const imgEl = row.find(".picSurround img"); |
| 212 | let img = imgEl.attr("data-src") || imgEl.attr("src") || row.find("img").first().attr("data-src") || row.find("img").first().attr("src"); |
| 213 | |
| 214 | const desc = row.find(".pt4").text().replace('read more.', '').trim(); |
| 215 | const type = row.find("td").eq(2).text().trim(); |
| 216 | const eps = row.find("td").eq(3).text().trim(); |
| 217 | const score = row.find("td").eq(4).text().trim(); |
| 218 | |
| 219 | results.push({ |
| 220 | title, |
| 221 | url: link, |
| 222 | thumbnail: this._formatImage(img), |
| 223 | description: desc, |
| 224 | type, |
| 225 | episodes: eps, |
| 226 | score: score !== 'N/A' ? score : '0.00' |
| 227 | }); |
| 228 | }); |
| 229 | return results; |
| 230 | } catch (error) { |
| 231 | throw new Error(`MAL Search Error: ${error.message}`); |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | /** |
| 236 | * Get top anime or upcoming |
| 237 | */ |
| 238 | async getTop(type = 'all') { |
| 239 | try { |
| 240 | const validTypes = ['all', 'airing', 'upcoming', 'tv', 'movie', 'ova', 'ona', 'special', 'bypopularity', 'favorite']; |
| 241 | const malType = validTypes.includes(type) ? (type === 'all' ? '' : type) : ''; |
| 242 | |
| 243 | const $ = await this.getHTML(`/topanime.php?type=${malType}`); |
| 244 | const results = []; |
| 245 | |
| 246 | $(".ranking-list").each((i, el) => { |
| 247 | const row = $(el); |
| 248 | const title = row.find(".title h3 a").text().trim(); |
| 249 | const link = row.find(".title h3 a").attr("href"); |
| 250 | const score = row.find(".score span").text().trim(); |
| 251 | const rank = row.find(".rank span").text().trim(); |
| 252 | |
| 253 | let img = row.find("img").attr("data-src") || row.find("img").attr("src"); |
| 254 | |
| 255 | const info = row.find(".information").text().trim().split('\n').map(x => x.trim()).filter(Boolean); |
| 256 | |
| 257 | if (title) { |
| 258 | results.push({ |
| 259 | rank: parseInt(rank), |
| 260 | title, |
| 261 | url: link, |
| 262 | score: score !== 'N/A' ? score : '0.00', |
| 263 | thumbnail: this._formatImage(img), |
| 264 | info: info[0] || '' |
| 265 | }); |
| 266 | } |
| 267 | }); |
| 268 | return results; |
| 269 | } catch (error) { |
| 270 | throw new Error(`MAL Top Error: ${error.message}`); |
| 271 | } |
| 272 | } |
| 273 | |
| 274 | /** |
| 275 | * Get detailed anime info |
| 276 | */ |
| 277 | async getDetail(url) { |
| 278 | try { |
| 279 | const $ = await this.getHTML(url); |
| 280 | |
| 281 | const title = $("h1.title-name").text().trim(); |
| 282 | const alternateTitle = $(".title-english").text().trim(); |
| 283 | const thumbnail = this._formatImage($("img[itemprop='image']").attr("data-src") || $("img[itemprop='image']").attr("src")); |
| 284 | const score = $(".score-label").first().text().trim(); |
| 285 | const rank = $(".ranked strong").text().trim().replace('#', ''); |
| 286 | const popularity = $(".popularity strong").text().trim().replace('#', ''); |
| 287 | const members = $(".members strong").text().trim(); |
| 288 | const synopsis = $("p[itemprop='description']").text().trim(); |
| 289 | |
| 290 | const info = {}; |
| 291 | $(".spaceit_pad").each((i, el) => { |
| 292 | const text = $(el).text().trim(); |
| 293 | if (text.includes(':')) { |
| 294 | const parts = text.split(':'); |
| 295 | const key = parts[0].trim().toLowerCase().replace(/\s+/g, '_'); |
| 296 | const val = parts.slice(1).join(':').trim().replace(/\s+/g, ' '); |
| 297 | if (key && val && val !== 'Unknown' && !key.includes('score')) { |
| 298 | info[key] = val; |
| 299 | } |
| 300 | } |
| 301 | }); |
| 302 | |
| 303 | return { |
| 304 | title, |
| 305 | alternate_title: alternateTitle, |
| 306 | thumbnail, |
| 307 | score: score !== 'N/A' ? score : '0.00', |
| 308 | rank, |
| 309 | popularity, |
| 310 | members, |
| 311 | synopsis, |
| 312 | details: info |
| 313 | }; |
| 314 | } catch (error) { |
| 315 | throw new Error(`MAL Detail Error: ${error.message}`); |
| 316 | } |
| 317 | } |
| 318 | } |
| 319 | |
| 320 | export { BaseScraper, MyAnimeList }; |
| 321 | export default MyAnimeList; |
| 322 | |
| 323 | // ───────────────────────────────────────────────────────────────────── |
| 324 | // Self-test: jalankan langsung dengan `node myanimelist.js [args...]` |
| 325 | // ───────────────────────────────────────────────────────────────────── |
| 326 | if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { |
| 327 | const __exports = { MyAnimeList: MyAnimeList } |
| 328 | const __fns = Object.fromEntries(Object.entries(__exports).filter(([, v]) => typeof v === 'function')) |
| 329 | const __names = Object.keys(__fns) |
| 330 | if (__names.length === 0) { |
| 331 | console.log('Tidak ada fungsi yang bisa di-demo. Import modul ini dari kode lain.') |
| 332 | } else { |
| 333 | const __search = __names.find((n) => /search|cari/i.test(n)) |
| 334 | const __name = __search || __names[0] |
| 335 | const __fn = __fns[__name] |
| 336 | const __isClass = /^class\s/.test(Function.prototype.toString.call(__fn)) |
| 337 | let __args = process.argv.slice(2).map((a) => { try { return JSON.parse(a) } catch { return a } }) |
| 338 | if (__args.length === 0 && __search) __args.push('naruto') |
| 339 | if (__args.length === 0 && !__search && !__isClass && __fn.length > 0) { |
| 340 | console.log('Fungsi: ' + __names.join(', ')) |
| 341 | console.log('Contoh: node ' + "myanimelist.js" + ' ' + __name + ' <arg>') |
| 342 | } else { |
| 343 | ;(async () => { |
| 344 | try { |
| 345 | const r = __isClass ? new __fn(...__args) : await __fn(...__args) |
| 346 | console.log(JSON.stringify(r, null, 2)) |
| 347 | } catch (e) { |
| 348 | console.error('Error: ' + (e && e.message ? e.message : e)) |
| 349 | process.exitCode = 1 |
| 350 | } |
| 351 | })() |
| 352 | } |
| 353 | } |
| 354 | } |
| 355 | |
Discussion & Comments0
No comments yet — be the first to leave feedback or start a code discussion.
Sign in with your GitHub account to write comments and join the discussion.