/** * @fileoverview Modul Scraper Samehadaku dengan Arsitektur Multi-Mirror Failover. * * Mengimplementasikan web scraper tangguh untuk situs anime Samehadaku menggunakan `got-scraping`. * Mengadopsi impersonasi TLS dan HTTP/2 fingerprinting browser Chrome untuk melewati deteksi bot, * didukung mekanisme failover otomatis ke daftar mirror aktif saat domain utama mengalami kendala, * serta parser terpadu untuk pencarian anime, detail serial, rilis terbaru, dan batch download. * * @module scrapers/samehadaku * @version 2.1.0 * @author kyiov * @license MIT */ import { gotScraping } from 'got-scraping'; import * as cheerio from 'cheerio'; import dns from 'node:dns'; // Prioritaskan resolusi IPv4 untuk mencegah hambatan routing dns.setDefaultResultOrder('ipv4first'); /** @constant {string[]} Daftar domain mirror Samehadaku aktif untuk mekanisme failover */ const SAMEHADAKU_MIRRORS = [ 'https://v2.samehadaku.how', 'https://samehadaku.email', 'https://samehadaku.care', 'https://samehadaku.win' ]; /** @constant {string} Domain mirror fallback utama */ const DEFAULT_BASE_URL = SAMEHADAKU_MIRRORS[0]; /** * Klien scraper Samehadaku dengan dukungan mirror failover dinamis. */ export class SamehadakuScraper { /** * Menginisialisasi klien scraper Samehadaku. * * @param {Object} [options={}] - Opsi konfigurasi scraper * @param {string} [options.baseUrl] - Kustomisasi URL domain basis awal * @param {number} [options.timeout=20000] - Batas waktu request dalam milidetik */ constructor(options = {}) { this.mirrors = options.baseUrl ? [options.baseUrl, ...SAMEHADAKU_MIRRORS] : [...SAMEHADAKU_MIRRORS]; this.baseUrl = this.mirrors[0] || DEFAULT_BASE_URL; this.timeout = options.timeout || 20000; } async fetchHtml(targetUrl) { const fullUrl = targetUrl.startsWith('http') ? targetUrl : `${this.baseUrl}${targetUrl.startsWith('/') ? '' : '/'}${targetUrl}`; let lastError = null; try { const response = await gotScraping.get(fullUrl, { headers: { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', 'accept-language': 'id-ID,id;q=0.9,en-US;q=0.8,en;q=0.7' }, timeout: { request: this.timeout } }); return response.body; } catch (err) { lastError = err; for (const mirror of this.mirrors) { if (mirror === this.baseUrl) continue; try { const fallbackUrl = targetUrl.startsWith('http') ? targetUrl : `${mirror}${targetUrl.startsWith('/') ? '' : '/'}${targetUrl}`; const res = await gotScraping.get(fallbackUrl, { headers: { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36' }, timeout: { request: this.timeout } }); this.baseUrl = mirror; return res.body; } catch (e) { lastError = e; } } } throw lastError || new Error('Gagal menghubungi semua mirror Samehadaku.'); } async search(query, options = {}) { if (!query) throw new Error('Kata kunci pencarian anime tidak boleh kosong.'); const limit = options.limit || 15; const searchUrl = `${this.baseUrl}/?s=${encodeURIComponent(query)}`; const html = await this.fetchHtml(searchUrl); const $ = cheerio.load(html); const results = []; $('article, .animpost, .animepost, .relat, .post-item').each((_, el) => { if (results.length >= limit) return; const linkEl = $(el).find('a').first(); const href = $(el).find('.title a, h2 a, a[rel="bookmark"]').attr('href') || linkEl.attr('href'); let title = $(el).find('.title, h2, .entry-title, .tt').first().text().trim() || linkEl.attr('title') || ''; const thumb = $(el).find('img').attr('src') || $(el).find('img').attr('data-src'); const score = $(el).find('.score, .rating, .rating-star').first().text().trim(); let type = $(el).find('.type, .type-anime, .status').first().text().trim(); title = title.split('\n')[0].replace(/\s+/g, ' ').trim(); type = type.split('\n')[0].replace(/\s+/g, ' ').trim(); if (title && href && !results.some((r) => r.url === href)) { results.push({ title, url: href.startsWith('http') ? href : `${this.baseUrl}${href}`, thumbnail: thumb || null, image: thumb || null, score: score || null, type: type || null, }); } }); return { success: true, query, total_found: results.length, data: results, results }; } async latest(page = 1) { const pageUrl = page > 1 ? `${this.baseUrl}/page/${page}` : this.baseUrl; const html = await this.fetchHtml(pageUrl); const $ = cheerio.load(html); const episodes = []; $('.post-show ul li, .lstepsiode li, article, .animpost').each((_, el) => { const linkEl = $(el).find('a').first(); const href = linkEl.attr('href') || $(el).find('.entry-title a').attr('href'); const title = $(el).find('.entry-title, .title, h2').first().text().trim() || linkEl.attr('title'); const thumb = $(el).find('img').attr('src') || $(el).find('img').attr('data-src'); const date = $(el).find('.date, .posted-on, time').first().text().trim(); if (title && href && !episodes.some((e) => e.url === href)) { episodes.push({ title: title.replace(/\s+/g, ' ').trim(), url: href.startsWith('http') ? href : `${this.baseUrl}${href}`, thumbnail: thumb || null, image: thumb || null, date: date || null, }); } }); return { success: true, page, total_found: episodes.length, data: episodes, results: episodes }; } async anime(animeUrl) { let targetUrl = animeUrl; if (!targetUrl.startsWith('http')) { const sRes = await this.search(targetUrl, { limit: 1 }); if (!sRes.data?.length) throw new Error(`Anime "${targetUrl}" tidak ditemukan.`); targetUrl = sRes.data[0].url; } const html = await this.fetchHtml(targetUrl); const $ = cheerio.load(html); const title = $('h1.entry-title, h1').first().text().trim(); const poster = $('.thumb img, .poster img, .entry-content img').first().attr('src') || null; const synopsis = $('.desc, .entry-content p, .synopsis, .mindes').first().text().trim(); const score = $('.rating-value, .score, .rating').first().text().trim(); const metadata = {}; $('.spe span, .info-content span').each((_, el) => { const txt = $(el).text().trim(); if (txt.includes(':')) { const parts = txt.split(':'); const key = parts[0].trim().toLowerCase().replace(/\s+/g, '_'); const val = parts.slice(1).join(':').trim(); if (key && val) metadata[key] = val; } }); const episodes = []; $('.lstepsiode li, .episodelist li, ul.episode-list li, a[href*="-episode-"]').each((_, el) => { const epHref = $(el).is('a') ? $(el).attr('href') : $(el).find('a').first().attr('href'); let epTitle = $(el).find('.lchx, .title, .epsleft a').first().text().trim() || $(el).find('a').first().text().trim() || $(el).text().trim(); if (!epHref || !epHref.includes('-episode-')) return; if ( epHref.includes('facebook.com') || epHref.includes('twitter.com') || epHref.includes('whatsapp.com') || epHref.includes('t.me') || epHref.includes('pinterest.com') ) { return; } epTitle = epTitle.replace(/\s+/g, ' ').trim(); const halfLen = Math.floor(epTitle.length / 2); if (halfLen > 3 && epTitle.slice(0, halfLen) === epTitle.slice(halfLen)) { epTitle = epTitle.slice(0, halfLen); } if (!epTitle) { const slug = epHref.split('/').filter(Boolean).pop() || ''; epTitle = slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); } const epDate = $(el).find('.date, .epsright').first().text().trim(); if (!episodes.some((e) => e.url === epHref)) { episodes.push({ title: epTitle, url: epHref.startsWith('http') ? epHref : `${this.baseUrl}${epHref}`, date: epDate || null, }); } }); return { success: true, title: title || 'Anime Samehadaku', url: targetUrl, poster, image: poster, score: score || metadata.score || null, status: metadata.status || null, total_episodes: episodes.length, synopsis, metadata, episodes, }; } async episode(episodeUrl) { const html = await this.fetchHtml(episodeUrl); const $ = cheerio.load(html); const title = $('h1.entry-title, h1').first().text().trim(); const date = $('.entry-date, time, .published').first().text().trim(); const streamServers = []; $('.east_player_option, #server-list li, ul.server-list li').each((_, el) => { const sName = $(el).find('span').text().trim() || $(el).text().trim(); const sPost = $(el).attr('data-post'); const sNume = $(el).attr('data-nume'); const sType = $(el).attr('data-type'); if (sName && sPost) { streamServers.push({ server: sName, post_id: sPost, nume: sNume, type: sType, }); } }); const downloadSections = {}; const allDownloadUrls = []; let currentFormat = 'MKV'; $('.download-eps, .box-download, .download-area, div.links_table').find('h4, b, strong, p, ul, li').each((_, el) => { const text = $(el).text().replace(/\s+/g, ' ').trim(); const tagName = el.tagName?.toLowerCase(); if ((tagName === 'h4' || tagName === 'b' || tagName === 'strong') && (text === 'MKV' || text === 'MP4' || text.includes('x265') || text.includes('HEVC'))) { if (text.includes('x265')) currentFormat = 'x265 [HEVC]'; else if (text === 'MP4') currentFormat = 'MP4'; else if (text === 'MKV') currentFormat = 'MKV'; if (!downloadSections[currentFormat]) downloadSections[currentFormat] = []; return; } if (!downloadSections[currentFormat]) downloadSections[currentFormat] = []; const resMatch = text.match(/(360p|480p|720p|1080p|MP4HD|FULLHD)/i); if (resMatch && $(el).find('a').length > 0) { const resolution = resMatch[1]; const servers = []; $(el).find('a').each((_, a) => { const linkHref = $(a).attr('href'); const linkName = $(a).text().trim(); if (linkHref && (linkHref.startsWith('http') || linkHref.startsWith('/')) && linkName.length > 0) { servers.push({ server: linkName, url: linkHref, }); allDownloadUrls.push(linkHref); } }); if (servers.length > 0) { const existing = downloadSections[currentFormat].find((r) => r.resolution.toLowerCase() === resolution.toLowerCase()); if (!existing) { downloadSections[currentFormat].push({ resolution, mirrors_count: servers.length, mirrors: servers, }); } } } }); const iframeSrc = $('iframe').first().attr('src') || ''; return { success: true, title: title || 'Episode Samehadaku', url: episodeUrl, date: date || null, iframe: iframeSrc, streaming_servers_count: streamServers.length, streaming_servers: streamServers, downloads: downloadSections, downloadLinks: [...new Set(allDownloadUrls)] }; } async schedule(day = 'all') { const DAY_MAP = { 'senin': 'monday', 'monday': 'monday', 'selasa': 'tuesday', 'tuesday': 'tuesday', 'rabu': 'wednesday', 'wednesday': 'wednesday', 'kamis': 'thursday', 'thursday': 'thursday', 'jumat': 'friday', 'friday': 'friday', 'sabtu': 'saturday', 'saturday': 'saturday', 'minggu': 'sunday', 'sunday': 'sunday', }; const targetDay = String(day || 'all').toLowerCase().trim(); if (targetDay !== 'all' && DAY_MAP[targetDay]) { const enDay = DAY_MAP[targetDay]; const apiUrl = `${this.baseUrl}/wp-json/custom/v1/all-schedule?perpage=50&day=${enDay}`; const res = await this.fetchHtml(apiUrl); const rawText = cheerio.load(res)('pre').text() || cheerio.load(res)('body').text() || res; const list = typeof rawText === 'object' ? rawText : JSON.parse(rawText.trim()); const formatted = (list || []).map((item) => ({ title: item.title, url: item.url, score: item.east_score || null, type: item.east_type || 'TV', genres: item.genre ? item.genre.split(',').map((g) => g.trim()) : [], air_time: item.east_time || null, day: targetDay.toUpperCase(), thumbnail: item.featured_img_src || null, synopsis: item.content || null, })); return { success: true, day: targetDay, total_anime: formatted.length, data: formatted, }; } const scheduleByDays = {}; for (const [idDay, enDay] of Object.entries(DAY_MAP)) { if (['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'].includes(idDay)) continue; try { const apiUrl = `${this.baseUrl}/wp-json/custom/v1/all-schedule?perpage=50&day=${enDay}`; const res = await this.fetchHtml(apiUrl); const rawText = cheerio.load(res)('pre').text() || cheerio.load(res)('body').text() || res; const list = typeof rawText === 'object' ? rawText : JSON.parse(rawText.trim()); scheduleByDays[idDay] = (list || []).map((item) => ({ title: item.title, url: item.url, score: item.east_score || null, type: item.east_type || 'TV', genres: item.genre ? item.genre.split(',').map((g) => g.trim()) : [], air_time: item.east_time || null, thumbnail: item.featured_img_src || null, })); } catch (_) { scheduleByDays[idDay] = []; } } return { success: true, schedule: scheduleByDays, }; } async genres() { const html = await this.fetchHtml(`${this.baseUrl}/daftar-anime-2/`); const $ = cheerio.load(html); const genres = []; $('a[href*="/genre/"]').each((_, el) => { const href = $(el).attr('href'); const name = $(el).text().trim(); if (name && !genres.some((g) => g.name.toLowerCase() === name.toLowerCase())) { const slug = href.replace(/^.*\/genre\//, '').replace(/\/$/, ''); genres.push({ name, slug, url: href.startsWith('http') ? href : `${this.baseUrl}${href}`, }); } }); return { success: true, total_genres: genres.length, genres, }; } async batches(page = 1) { const targetUrl = page > 1 ? `${this.baseUrl}/daftar-batch/page/${page}/` : `${this.baseUrl}/daftar-batch/`; const html = await this.fetchHtml(targetUrl); const $ = cheerio.load(html); const batches = []; $('.animpost, .animepost, article').each((_, el) => { const linkEl = $(el).find('a').first(); const href = linkEl.attr('href'); let title = $(el).find('.title, h2, .entry-title').first().text().trim() || linkEl.attr('title') || ''; const thumb = $(el).find('img').attr('src') || $(el).find('img').attr('data-src'); const score = $(el).find('.score').text().trim(); const type = $(el).find('.type').text().trim(); title = title.replace(/\s+/g, ' ').trim(); const matchTitle = title.match(/([^\n]+\[BATCH\])/i) || [title]; const cleanTitle = matchTitle[0].replace(/^(TV|Movie)\s*\d+(\.\d+)?/i, '').trim(); if (href && href.includes('/batch/') && !batches.some((b) => b.url === href)) { batches.push({ title: cleanTitle || title, url: href.startsWith('http') ? href : `${this.baseUrl}${href}`, thumbnail: thumb || null, score: score || null, type: type || 'Batch', }); } }); return { success: true, page: Number(page), total_found: batches.length, data: batches, }; } async batch(batchUrl) { const html = await this.fetchHtml(batchUrl); const $ = cheerio.load(html); const title = $('h1.entry-title, h1').first().text().trim(); const poster = $('.thumb img, .entry-content img').first().attr('src') || null; const synopsis = $('.desc, .entry-content p, .synopsis, .mindes').first().text().trim(); const downloadSections = {}; let currentFormat = 'MKV'; $('.download-eps, .box-download, .download-area, div.links_table').find('h4, b, strong, p, ul, li').each((_, el) => { const text = $(el).text().replace(/\s+/g, ' ').trim(); const tagName = el.tagName?.toLowerCase(); if ((tagName === 'h4' || tagName === 'b' || tagName === 'strong') && (text === 'MKV' || text === 'MP4' || text.includes('x265') || text.includes('HEVC'))) { if (text.includes('x265')) currentFormat = 'x265 [HEVC]'; else if (text === 'MP4') currentFormat = 'MP4'; else if (text === 'MKV') currentFormat = 'MKV'; if (!downloadSections[currentFormat]) downloadSections[currentFormat] = []; return; } if (!downloadSections[currentFormat]) downloadSections[currentFormat] = []; const resMatch = text.match(/(360p|480p|720p|1080p|MP4HD|FULLHD)/i); if (resMatch && $(el).find('a').length > 0) { const resolution = resMatch[1]; const servers = []; $(el).find('a').each((_, a) => { const linkHref = $(a).attr('href'); const linkName = $(a).text().trim(); if (linkHref && linkName.length > 0) { servers.push({ server: linkName, url: linkHref, }); } }); if (servers.length > 0) { const existing = downloadSections[currentFormat].find((r) => r.resolution.toLowerCase() === resolution.toLowerCase()); if (!existing) { downloadSections[currentFormat].push({ resolution, mirrors_count: servers.length, mirrors: servers, }); } } } }); return { success: true, title: title || 'Batch Samehadaku', url: batchUrl, poster, synopsis: synopsis || null, downloads: downloadSections, }; } } const defaultClient = new SamehadakuScraper(); export async function samehadakuSearch(keyword) { try { const res = await defaultClient.search(keyword); return { status: true, results: (res.data || []).map((item) => ({ id: item.url, title: item.title, url: item.url, image: item.thumbnail || '', genre: item.type || '-', type: item.type || 'TV', score: item.score || '-' })) }; } catch (e) { return { status: false, error: e.message }; } } export async function samehadakuLatest(page = 1) { return await defaultClient.latest(page); } export async function samehadakuDetail(url) { return await defaultClient.anime(url); } export async function samehadakuEpisode(url) { return await defaultClient.episode(url); } export async function samehadakuSchedule(day = 'all') { return await defaultClient.schedule(day); } export async function samehadakuBatches(page = 1) { return await defaultClient.batches(page); } export async function samehadakuBatch(url) { return await defaultClient.batch(url); } export async function samehadakuGenres() { return await defaultClient.genres(); } 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 samehadakuSearch('one piece'); console.log(JSON.stringify(res, null, 2)); } catch (err) { console.error('Galat Samehadaku:', err.message || err); } }