/** * @fileoverview Modul Scraper & Ekstraktor Donghua Anichin. * * Mengimplementasikan web scraper untuk portal streaming Donghua Anichin (anichin.moe). * Menggunakan mekanisme throttling / rate limiting cerdas (jeda minimum 1200ms per request) * untuk menjaga stabilitas koneksi, didukung resolver host pemutar video embed multi-server, * serta ekstraksi tautan download berbagai pilihan resolusi video. * * @module scrapers/anichin * @version 1.5.0 * @author kyiov * @license MIT */ import axios from 'axios' import * as cheerio from 'cheerio' import dns from 'node:dns' // Prioritaskan resolusi IPv4 untuk mencegah latensi rute dns.setDefaultResultOrder('ipv4first') /** @constant {string} Domain basis resmi Anichin */ const BASE_URL = 'https://anichin.moe' /** @constant {string} Header User-Agent browser modern */ const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' let lastReq = 0 async function fetchPage(url) { const now = Date.now() if (now - lastReq < 1200) { await new Promise((r) => setTimeout(r, 1200 - (now - lastReq))) } try { const fullUrl = url.startsWith('http') ? url : BASE_URL + (url.startsWith('/') ? '' : '/') + url const res = await axios.get(fullUrl, { headers: { 'User-Agent': UA, 'Referer': BASE_URL + '/', 'Accept': 'text/html,*/*', }, timeout: 30000, }) lastReq = Date.now() return cheerio.load(res.data) } catch (e) { console.error('[Anichin Fetch Error]:', url, e.message) return null } } function parseList($) { const hasil = [] $('.bs .bsx, .listupd .bsx').each((_, el) => { const $el = $(el) const a = $el.find('a').first() const href = a.attr('href') || '' const img = $el.find('img').first() hasil.push({ judul: $el.find('.tt h2, .tt h3, h2, h3').first().text().trim(), url: href.startsWith('http') ? href : BASE_URL + (href.startsWith('/') ? '' : '/') + href, gambar: img.attr('src') || img.attr('data-src') || '', episode: $el.find('.epx, .ep, .newnime').first().text().trim(), tipe: $el.find('.typez, .type').first().text().trim(), }) }) return hasil } export async function searchDonghua(query, pages = 1) { const semua = [] const q = encodeURIComponent(query) for (let i = 1; i <= pages; i++) { const url = i > 1 ? `${BASE_URL}/page/${i}/?s=${q}` : `${BASE_URL}/?s=${q}` const $ = await fetchPage(url) if (!$) break const list = parseList($) if (!list.length) break semua.push(...list) } return semua } export async function getOngoingDonghua(pages = 1) { const semua = [] for (let i = 1; i <= pages; i++) { const url = i > 1 ? `${BASE_URL}/ongoing/page/${i}/` : `${BASE_URL}/ongoing/` const $ = await fetchPage(url) if (!$) break const list = parseList($) if (!list.length) break list.forEach((a) => (a.status = 'Ongoing')) semua.push(...list) } return semua } export async function getDetailDonghua(url) { const $ = await fetchPage(url) if (!$) throw new Error('Gagal mengambil detail donghua') const info = {} $('.info-content .spe span, .single-info .spe span').each((_, el) => { const teks = $(el).text().trim() if (teks.includes(':')) { const [k, ...v] = teks.split(':') info[k.trim()] = v.join(':').trim() } }) const genre = [] $('.info-content .genxed a, .genre-info a').each((_, el) => { const g = $(el).text().trim() if (g) genre.push(g) }) if (genre.length) info.genre = genre const episode = [] $('.eplister ul li').each((_, el) => { const link = $(el).find('a') const href = link.attr('href') || '' const epNum = $(el).find('.epl-num').text().trim() const epTitle = $(el).find('.epl-title, .epl-sub').text().trim() const epDate = $(el).find('.epl-date, .newnime').text().trim() episode.push({ episode: epNum || `Ep ${episode.length + 1}`, judul: epTitle, tanggal: epDate, url: href.startsWith('http') ? href : BASE_URL + (href.startsWith('/') ? '' : '/') + href, }) }) const img = $('.thumb img, .poster img, .ts-post-image').first() return { url, judul: $('h1.entry-title').first().text().trim(), gambar: img.attr('src') || img.attr('data-src') || '', sinopsis: $('.entry-content .spe p, .description .sinopsis, .sinopsis').first().text().trim(), info, episode, totalEpisode: episode.length, } } export async function resolveStreamHost(streamGatewayUrl) { try { const target = streamGatewayUrl.startsWith('http') ? streamGatewayUrl : BASE_URL + (streamGatewayUrl.startsWith('/') ? '' : '/') + streamGatewayUrl const res = await axios.get(target, { headers: { 'User-Agent': UA, 'Referer': BASE_URL + '/', }, timeout: 10000, }) const $ = cheerio.load(res.data) let finalUrl = $('iframe').attr('src') || '' let serverType = 'Player' if (finalUrl.includes('rumble.com')) serverType = 'Rumble' else if (finalUrl.includes('ok.ru')) serverType = 'OK.ru' else if (finalUrl.includes('dailymotion')) serverType = 'Dailymotion' else if (finalUrl.includes('odysee')) serverType = 'Odysee' else if (finalUrl.includes('streamtape')) serverType = 'Streamtape' return { server: serverType, streamUrl: finalUrl || target, rawGateway: target } } catch (e) { return { server: 'Stream Gateway', streamUrl: streamGatewayUrl, error: e.message } } } export async function getEpisodeStreams(url) { const $ = await fetchPage(url) if (!$) throw new Error('Gagal mengambil data episode donghua') const servers = [] $('.player-embed iframe, .player iframe').each((_, el) => { const src = $(el).attr('src') || '' if (src) servers.push(src) }) $('select.mirror option').each((_, el) => { let val = $(el).attr('value') || '' if (val) { if (/^[A-Za-z0-9+/=]{30,}$/.test(val)) { try { const decoded = Buffer.from(val, 'base64').toString('utf8') const match = decoded.match(/src=["']([^"']+)["']/) if (match) val = match[1] } catch {} } if (val && !servers.includes(val)) servers.push(val) } }) const resolvedStreams = [] for (const s of servers.slice(0, 6)) { const res = await resolveStreamHost(s) if (res.streamUrl) resolvedStreams.push(res) } const downloads = [] $('.dlbox ul li, .download ul li, .mctnx a').each((_, el) => { const a = $(el).is('a') ? $(el) : $(el).find('a') const href = a.attr('href') || '' const text = $(el).text().trim().replace(/\s+/g, ' ') if (href && !downloads.some(d => d.url === href)) { downloads.push({ quality: text || 'Download Link', url: href }) } }) return { url, judul: $('h1.entry-title').first().text().trim(), streams: resolvedStreams, downloads } } export default { searchDonghua, getOngoingDonghua, getDetailDonghua, getEpisodeStreams } import { pathToFileURL } from 'node:url' // Contoh pemanggilan langsung jika file dieksekusi secara mandiri if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { try { const res = await searchDonghua('renegade') console.log(JSON.stringify(res, null, 2)) } catch (err) { console.error('Galat Anichin:', err.message || err) } }