• Scraper ringan untuk mengambil data dari web Roseveil.org tanpa library tambahan (seperti Cheerio atau Puppeteer). • Memanfaatkan fitur fetch bawaan JavaScript dan Regex untuk mengekstrak data JSON SvelteKit langsung dari elemen <script>. • Berisi 3 fungsi utama yang siap di-import ke project berbasis Node.js: getHomeData (untuk Popular/Latest), getNovelDetail (berdasarkan slug), dan getChapterContent. • Feel free untuk memodifikasi atau menghapus komentar di bagian "Contoh Penggunaan" paling bawah sesuai kebutuhan.
Note
Ini cuman proyek gabut,jd kalo kurang rapih atau udah gak work fix sndiri aja 😊
Contributed by
@7771 contribution
777
javascript
7
1
—
13 Agu 2026
Code
// roseveil-scraper.js
// Scraper untuk Roseveil.org
// Menggunakan fetch (Node.js 18+ atau browser)
const BASE_URL = 'https://roseveil.org';
async function fetcher(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status} - ${response.statusText}`);
}
const data = await response.text();
return { data };
}
function extractScriptData(html, urlPattern) {
const regex = new RegExp(
`<script[^>]*data-sveltekit-fetched[^>]*data-url="([^"]*${urlPattern}[^"]*)"[^>]*>([\\s\\S]*?)<\\/script>`,
'i'
);
const match = html.match(regex);
if (!match) return null;
try {
const json = JSON.parse(match[2]);
const parsedBody = JSON.parse(json.body);
return parsedBody;
} catch (_) {
return null;
}
}
export async function getHomeData() {
try {
const url = `${BASE_URL}/novels`;
const { data: html } = await fetcher(url);
const popularData = extractScriptData(html, '/api/search\\?sort=popular');
const latestData = extractScriptData(html, '/api/search\\?sort=latest');
const popular = (popularData?.data || [])
.filter(item => item.type === 'NOVEL')
.map(item => ({
id: item.id,
title: item.title,
slug: item.slug,
cover: item.poster_image_url,
rating: item.rating_average,
views: item.view_count,
followers: item.followers_count,
author: item.author_name,
status: item.series_status,
type: item.novel_subtype,
genres: (item.genres || []).map(g => g.name || g),
synopsis: item.synopsis,
}));
const latest = (latestData?.data || [])
.filter(item => item.type === 'NOVEL')
.map(item => ({
id: item.id,
title: item.title,
slug: item.slug,
cover: item.poster_image_url,
rating: item.rating_average,
views: item.view_count,
followers: item.followers_count,
author: item.author_name,
status: item.series_status,
type: item.novel_subtype,
genres: (item.genres || []).map(g => g.name || g),
synopsis: item.synopsis,
}));
return { popular, latest };
} catch (error) {
throw new Error(`Gagal mengambil data home: ${error.message}`);
}
}
export async function getNovelDetail(slug) {
if (!slug) throw new Error('Parameter slug wajib diisi');
try {
const url = `${BASE_URL}/novels/${slug}`;
const { data: html } = await fetcher(url);
const parsed = extractScriptData(html, '/api/series/novels/');
if (!parsed) throw new Error('Data novel tidak ditemukan');
const {
id,
title,
slug: seriesSlug,
synopsis,
poster_image_url,
rating_average,
view_count,
followers_count,
author_name,
publisher_name,
series_status,
novel_subtype,
genres,
units,
uploaders,
} = parsed;
return {
id,
title,
slug: seriesSlug,
synopsis: synopsis || 'Tidak ada sinopsis',
cover: poster_image_url,
rating: rating_average || '0.0',
views: view_count || 0,
followers: followers_count || 0,
author: author_name || 'Unknown',
publisher: publisher_name || 'Unknown',
status: series_status || 'Unknown',
type: novel_subtype || 'NOVEL',
genres: (genres || []).map(g => g.name || g),
chapters: (units || []).map(ch => ({
number: ch.chapter_number || ch.number,
title: ch.title || `Chapter ${ch.chapter_number || ch.number}`,
slug: ch.slug,
coin_price: ch.coin_price || 0,
is_premium: ch.is_premium || false,
is_locked: ch.is_locked || false,
created_at: ch.created_at || null,
})),
uploaders: (uploaders || []).map(u => ({
username: u.username,
avatar: u.avatar_url,
role: u.role,
})),
};
} catch (error) {
throw new Error(`Gagal mengambil detail novel: ${error.message}`);
}
}
export async function getChapterContent(slug, chapter) {
if (!slug || !chapter) throw new Error('Parameter slug dan chapter wajib diisi');
try {
const url = `${BASE_URL}/novels/${slug}/chapter/${chapter}`;
const { data: html } = await fetcher(url);
const parsed = extractScriptData(html, '/api/series/novels/.*?/chapter/');
if (!parsed) throw new Error('Data chapter tidak ditemukan');
const { series, chapter: chapterData, units, previous_chapter, next_chapter } = parsed;
return {
series: {
id: series.id,
title: series.title,
slug: series.slug,
cover: series.poster_image_url,
rating: series.rating_average,
},
chapter: {
number: chapterData.chapter_number,
title: chapterData.title,
slug: chapterData.slug,
content: chapterData.content || '',
word_count: chapterData.word_count || 0,
is_premium: chapterData.is_premium || false,
is_locked: chapterData.is_locked || false,
coin_price: chapterData.coin_price || 0,
created_at: chapterData.created_at,
},
chapter_list: (units || []).map(ch => ({
number: ch.chapter_number,
title: ch.title,
slug: ch.slug,
is_premium: ch.is_premium || false,
is_locked: ch.is_locked || false,
})),
navigation: {
previous: previous_chapter
? {
number: previous_chapter.chapter_number,
title: previous_chapter.title,
slug: previous_chapter.slug,
}
: null,
next: next_chapter
? {
number: next_chapter.chapter_number,
title: next_chapter.title,
slug: next_chapter.slug,
}
: null,
},
};
} catch (error) {
throw new Error(`Gagal mengambil chapter: ${error.message}`);
}
}
// Contoh penggunaan (jika di-run langsung)
// (async () => {
// try {
// const home = await getHomeData();
// console.log('Home:', home);
// const detail = await getNovelDetail('judul-novel');
// console.log('Detail:', detail);
// const chapter = await getChapterContent('judul-novel', '1');
// console.log('Chapter:', chapter);
// } catch (e) {
// console.error(e);
// }
// })();Rating
Gimana snippet ini menurutmu?
Embed ke website / blog
<iframe src="https://qrtzcode.vercel.app/api/embed/library/roseveil-novel-scraper-sveltekit-json-parser" style="width:100%;height:400px;border:none;border-radius:12px;"></iframe>