/** * Webtoon * base: https://m.webtoons.com * Creator: ShanMolvyr * Jangan Hapus Kreator hargai creator LAH hehe * Note: cek https://snippet.vyr.my.id/shanmolvyr/webtoon/README.md * Sumber: https://whatsapp.com/channel/0029VbB4Kw8EFeXfeExaXc3Q */ const axios = require('axios'); const cheerio = require('cheerio'); const BASE = 'https://m.webtoons.com'; const client = axios.create({ baseURL: BASE, headers: { 'User-Agent': 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Mobile Safari/537.36', 'Accept-Language': 'id-ID,id;q=0.9,en-US;q=0.8,en;q=0.7', Cookie: 'locale=id; needGDPR=false; needCCPA=false; needCOPPA=false; countryCode=ID', }, }); function parseTitleItems($, scope) { const items = []; scope.each((_, el) => { const $el = $(el); const href = $el.attr('href') || ''; const titleNo = $el.attr('data-title-no') || ''; const type = $el.attr('data-webtoon-type') || ''; const title = $el.find('.info_text .title').first().text().trim(); const genre = $el.find('.info_text .genre').first().text().trim(); const thumbnail = $el.find('img').first().attr('src') || ''; const views = $el.find('.view_count').first().text().trim(); const rank = $el.attr('data-rank') || null; if (!titleNo) return; items.push({ titleNo, title, genre: genre || null, type, thumbnail, views: views || null, rank: rank ? Number(rank) : null, url: href.replace(/^https?:\/\/m\.webtoons\.com/, ''), }); }); return items; } async function getHome() { const { data: html } = await client.get('/id'); const $ = cheerio.load(html); const section = (title) => $('h2.section_title') .filter((_, el) => $(el).text().trim() === title) .closest('a.section_header') .parent(); const trending = parseTitleItems($, $('ul._trending_page .link._titleItem')); const popular = parseTitleItems($, $('ul._popular_page .link._titleItem')); const newReleases = parseTitleItems($, section('Serial Terbaru Untukmu').find('.link._titleItem')); const dailySchedule = parseTitleItems($, $('#daily_list .link._titleItem')); const genreTabs = []; $('._category_tab_button').each((_, el) => { genreTabs.push({ code: $(el).attr('data-href-code'), name: $(el).text().trim(), active: $(el).attr('aria-selected') === 'true', }); }); const genrePopular = parseTitleItems($, $('#genre_list .link._titleItem')); const creators = parseTitleItems($, $('._canvas_page .link._titleItem')); return { trending, popular, newReleases, dailySchedule, genreTabs, genrePopular, creators }; } function genreSlug(genre) { return String(genre || '').toLowerCase().replace(/_/g, '-'); } function slugify(str) { return String(str || '') .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); } function mapSearchItem(item, type) { const genrePath = type === 'CHALLENGE' ? 'canvas' : genreSlug(item.representGenre); const slug = item.titleGroupName || slugify(item.title); return { titleNo: String(item.titleNo), title: item.title, genre: item.representGenre || null, type, thumbnail: item.thumbnailMobile ? `https://webtoon-phinf.pstatic.net${item.thumbnailMobile}` : '', authors: [item.writingAuthorName, item.pictureAuthorName].filter(Boolean), readCount: item.readCount, unsuitableForChildren: item.unsuitableForChildren, url: `/id/${genrePath}/${slug}/list?title_no=${item.titleNo}`, }; } async function search(keyword, page = 1) { const start = (page - 1) * 10 + 1; const { data } = await client.get('/id/search/result', { params: { keyword, searchType: 'ALL', start }, }); const webtoon = data?.result?.webtoonResult?.titleList || []; const canvas = data?.result?.challengeResult?.titleList || []; return { keyword, page, totalWebtoon: data?.result?.webtoonResult?.totalCount || 0, totalCanvas: data?.result?.challengeResult?.totalCount || 0, results: [ ...webtoon.map((i) => mapSearchItem(i, 'WEBTOON')), ...canvas.map((i) => mapSearchItem(i, 'CHALLENGE')), ], }; } async function getDetail(url, cursor = 0) { const { data: html } = await client.get(url); const $ = cheerio.load(html); const info = $('.detail_info_wrap'); const title = info.find('.subject').first().text().trim(); const thumbnail = info.find('.img_area img').first().attr('src') || ''; const authors = info .find('.author') .first() .text() .split(',') .map((s) => s.trim()) .filter(Boolean); const summary = info.find('.summary').first().text().trim(); const ranking = info.find('.point').first().text().trim() || null; const tags = []; info.find('.tag_box .tag').each((_, el) => tags.push($(el).text().trim())); const titleNoMatch = url.match(/title_no=(\d+)/); const titleNo = titleNoMatch ? titleNoMatch[1] : null; let episodes = []; let nextCursor = null; if (titleNo) { const reqOpts = { params: { pageSize: 30, cursor: cursor || undefined }, headers: { 'x-requested-with': 'XMLHttpRequest', Referer: `${BASE}${url}` }, }; let data; try { const res = await client.get(`/api/v1/webtoon/${titleNo}/episodes`, reqOpts); data = res.data; } catch { const res = await client.get(`/api/v1/canvas/${titleNo}/episodes`, reqOpts); data = res.data; } const list = data?.result?.episodeList || []; nextCursor = data?.result?.nextCursor ?? null; episodes = list.map((ep) => ({ episodeNo: ep.episodeNo, title: ep.episodeTitle, date: ep.exposureDateMillis ? new Date(ep.exposureDateMillis).toISOString() : null, thumbnail: ep.thumbnail ? `https://webtoon-phinf.pstatic.net${ep.thumbnail}` : '', url: ep.viewerLink, })); } return { title, thumbnail, authors, summary, ranking, tags, titleNo, episodes, nextCursor }; } async function read(url) { const { data: html } = await client.get(url); const $ = cheerio.load(html); const images = []; const match = html.match(/var\s+imageList\s*=\s*(\[[\s\S]*?\]);/); if (match) { const block = match[1]; const re = /url:\s*"([^"]+)"[\s\S]*?width:\s*(\d+)[\s\S]*?height:\s*(\d+)[\s\S]*?sortOrder:\s*(\d+)/g; let m; while ((m = re.exec(block)) !== null) { images.push({ url: m[1], width: Number(m[2]), height: Number(m[3]), sortOrder: Number(m[4]) }); } images.sort((a, b) => a.sortOrder - b.sortOrder); } const title = $('h1.h1_viewer').first().text().trim(); const nextMatch = html.match(/nextEpisodeUrl:\s*"([^"]*)"/); const prevMatch = html.match(/prevEpisodeUrl:\s*"([^"]*)"/); return { url, title: title || null, images, nextEpisodeUrl: nextMatch ? nextMatch[1] || null : null, prevEpisodeUrl: prevMatch ? prevMatch[1] || null : null, }; } async function getImageBuffer(url) { const res = await axios.get(url, { responseType: 'arraybuffer', headers: { Referer: 'https://www.webtoons.com/', 'User-Agent': client.defaults.headers['User-Agent'], }, }); return { buffer: Buffer.from(res.data), contentType: res.headers['content-type'] }; } module.exports = { getHome, search, getDetail, read, getImageBuffer }; if (require.main === module) { const fs = require('fs'); const path = require('path'); const [cmd, ...args] = process.argv.slice(2); const printJSON = (data) => console.log(JSON.stringify(data, null, 2)); const help = () => { console.log(`Webtoon CLI by ShanMolvyr Usage: Cek https://snippet.vyr.my.id/shanmolvyr/webtoon/README.md`); }; const downloadImages = async (images, dir) => { fs.mkdirSync(dir, { recursive: true }); for (const img of images) { const ext = path.extname(new URL(img.url).pathname) || '.jpg'; const filename = String(img.sortOrder).padStart(3, '0') + ext; const filepath = path.join(dir, filename); const { buffer } = await getImageBuffer(img.url); fs.writeFileSync(filepath, buffer); console.log(`saved ${filepath}`); } }; (async () => { try { switch (cmd) { case 'home': printJSON(await getHome()); break; case 'search': if (!args[0]) return help(); printJSON(await search(args[0])); break; case 'detail': { if (!args[0]) return help(); const cursor = args[1] ? Number(args[1]) : 0; printJSON(await getDetail(args[0], cursor)); break; } case 'read': { if (!args[0]) return help(); const downloadIdx = args.indexOf('--download'); const data = await read(args[0]); if (downloadIdx !== -1) { const dir = args[downloadIdx + 1] || './output'; await downloadImages(data.images, dir); delete data.images; } printJSON(data); break; } case 'raw': { if (!args[0] || !args[1]) return help(); const { data } = await client.get(args[0]); const out = typeof data === 'string' ? data : JSON.stringify(data, null, 2); fs.writeFileSync(args[1], out); console.log(`saved ${args[1]} (${out.length} chars)`); break; } default: help(); } } catch (err) { console.error('Error:', err.message); process.exit(1); } })(); }