Back to Home
Gistify Snippet
kyiov
13h ago·1 file(s)
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
Raw
myanimelist.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
15import * as __ns_241 from 'cheerio'
16const cheerio = __ns_241
17import * as __ns_242 from 'node:url'
18const 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 */
27class 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 */
170class 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
320export { BaseScraper, MyAnimeList };
321export default MyAnimeList;
322
323// ─────────────────────────────────────────────────────────────────────
324// Self-test: jalankan langsung dengan `node myanimelist.js [args...]`
325// ─────────────────────────────────────────────────────────────────────
326if (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.