Loading snippets...
get home, detail, search, read Webtoon
/**
* 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);
}
})();
}
bashnpm install axios cheerio
bashnode webtoon.js home node webtoon.js search <keyword> node webtoon.js detail <url> [cursor] node webtoon.js read <url> [--download <folder>] node webtoon.js raw <url> <output.html>
bashnode webtoon.js home
bashnode webtoon.js search "incandescent bloom"
bashnode webtoon.js detail "/id/canvas/incandescent-bloom/list?title_no=1142020"
bashnode webtoon.js detail "/id/canvas/incandescent-bloom/list?title_no=1142020" 30
bashnode webtoon.js read "/id/canvas/incandescent-bloom/episode-1/viewer?title_no=1142020&episode_no=1"
bashnode webtoon.js read "...&episode_no=1" --download ./ep1
kalau url read di enter ke browser jelas gabisa bakal kena block, jadi caranya download oke?