LOADING
bilibili/Bstation downloader tanpa watermark, support 720p+ langsung ke bstation/bilibili
#!/usr/bin/env node
import axios from 'axios';
import fs from 'fs';
import { spawn } from 'child_process';
import { existsSync, readFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
function loadEnv() {
const candidates = [...new Set([
resolve(__dirname, '.env'),
resolve(process.cwd(), '.env'),
resolve(dirname(process.argv[1]||''), '.env'),
])];
const p = candidates.find(existsSync);
if (!p) return {};
const out = {};
for (const raw of readFileSync(p,'utf8').split('\n')) {
const l = raw.trim();
if (!l || l.startsWith('#')) continue;
const i = l.indexOf('=');
if (i < 0) continue;
out[l.slice(0,i).trim()] = l.slice(i+1).trim().replace(/^["'](.*)["']$/, '$1');
}
return out;
}
const ENV = loadEnv();
const QUALITY_MAP = { 5:'144P', 6:'240P', 16:'360P', 32:'480P', 64:'720P', 80:'1080P', 112:'1080P HD', 116:'1080P 60FPS', 120:'4K' };
const ORDER = ['4K','1080P 60FPS','1080P HD','1080P','720P','480P','360P','240P','144P'];
const SHORT_DOMAINS = ['bili.im','b23.tv','b23.wtf','b23.tf','b23.icu','b23.app','bili2233.cn','bili2233.ch','b23bb.tv','bstation.app'];
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';
const REF = 'https://www.bilibili.tv/';
const fmtSize = b => !b || isNaN(b) ? null : b >= 1048576 ? (b/1048576).toFixed(1)+' MB' : Math.round(b/1024)+' KB';
const fmtDur = ms => {
if (!ms) return null;
const t = Math.round(ms/1000), h = Math.floor(t/3600), m = Math.floor((t%3600)/60), s = t%60;
return h > 0 ? [h,m,s].map(v=>String(v).padStart(2,'0')).join(':') : [m,s].map(v=>String(v).padStart(2,'0')).join(':');
};
const isHevc = (c='') => /hev|hvc/i.test(c);
export async function resolveShortUrl(input) {
let url = String(input).trim();
if (!url) return input;
if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
let parsed; try { parsed = new URL(url); } catch { return input; }
const host = parsed.hostname.replace(/^www\./i,'').toLowerCase();
if (!SHORT_DOMAINS.some(d => host === d || host.endsWith('.'+d))) return input;
try {
const r = await axios.get(url, { maxRedirects:0, timeout:5000, headers:{ 'User-Agent':UA, Accept:'text/html' }, validateStatus: s => s < 400 });
if (r.headers.location) return new URL(r.headers.location, url).href;
} catch(e) {
if (e.response?.headers?.location) return new URL(e.response.headers.location, url).href;
}
try {
const r2 = await axios.get(url, { maxRedirects:8, timeout:8000, headers:{ 'User-Agent':UA } });
const final = r2.request?.res?.responseUrl || r2.request?._redirectable?._currentUrl;
if (final && final !== url) return final;
} catch(_) {}
return input;
}
export function parseBilibiliTvUrl(input) {
if (!input) throw new Error('URL / ID kosong');
let s = String(input).trim();
if (/^\d+$/.test(s)) return { raw_id: s };
if (!/^https?:\/\//i.test(s)) s = 'https://' + s;
let url; try { url = new URL(s); } catch { throw new Error('URL tidak valid'); }
const p = url.pathname;
const play = p.match(/\/play\/(\d+)(?:\/(\d+))?/i);
if (play) return { type:'ogv', season_id:play[1], ep_id:play[2]||null };
const vid = p.match(/\/(?:id|en|vi|th|ms|zh)?\/video\/(\d+)/i) || p.match(/\/video\/(\d+)/i);
if (vid) return { type:'ugc', aid:vid[1] };
const any = p.match(/\/(\d{10,})/);
if (any) return { raw_id: any[1] };
throw new Error('Format tidak dikenali. Gunakan: /video/AID, /play/SEASON/EP, short link, atau raw ID');
}
export async function getBilibiliTvStreams(input, { qn=120, cookie='', locale='id_ID' }={}) {
const resolved = await resolveShortUrl(input);
const parsed = parseBilibiliTvUrl(resolved);
const params = { s_locale:locale, platform:'web', qn, type:0, device:'wap', tf:0 };
if (parsed.type === 'ogv') {
if (!parsed.ep_id) throw new Error('ep_id dibutuhkan untuk URL /play/ (gunakan /play/SEASON/EP)');
params.ep_id = parsed.ep_id;
} else {
params.aid = parsed.aid || parsed.raw_id;
}
const headers = { 'User-Agent':UA, Referer:REF, Origin:'https://www.bilibili.tv' };
if (cookie) headers.Cookie = cookie;
const call = p => axios.get('https://api.bilibili.tv/intl/gateway/web/playurl', { params:p, headers, timeout:20000 }).then(r => r.data);
let data = await call(params).catch(e => { throw new Error('Gagal request API: '+e.message); });
if (data.code !== 0 && parsed.raw_id && params.aid) {
params.ep_id = parsed.raw_id; delete params.aid;
data = await call(params);
}
if (data.code !== 0) {
const msg = data.message || String(data.code);
if (data.code === 10004001) throw new Error('Geo-restricted / tidak tersedia di wilayahmu');
if ([10004004,10004005,10023006].includes(data.code)) throw new Error('Butuh login / premium: '+msg);
throw new Error('API Error: '+msg);
}
const playurl = data.data?.playurl;
if (!playurl) throw new Error('Tidak ada data playurl');
const allVids = (playurl.video||[]).map(v => {
const r = v.video_resource||v;
return { quality_text:QUALITY_MAP[r.quality]||String(r.quality), codecs:r.codecs||'', size:r.size, url:r.url||null };
});
const vg = {}, lockedSet = new Set();
for (const v of allVids) {
const k = v.quality_text;
if (v.url && (!vg[k] || (isHevc(v.codecs) && !isHevc(vg[k].codecs)))) vg[k] = v;
else if (!v.url) lockedSet.add(k);
}
const videos = Object.fromEntries(ORDER.filter(q=>vg[q]).map(q => [q, { url:vg[q].url, size:fmtSize(vg[q].size), codec:isHevc(vg[q].codecs)?'hevc':'avc' }]));
const locked_qualities = ORDER.filter(q => lockedSet.has(q) && !vg[q]);
let bestAudio = null;
for (const a of (playurl.audio_resource||playurl.audio||[])) {
const r = a.audio_resource||a;
if (!r.url) continue;
if (!bestAudio || (r.bandwidth||0) > (bestAudio.bandwidth||0)) bestAudio = { url:r.url, size:fmtSize(r.size), bandwidth:r.bandwidth||0, codecs:r.codecs||'mp4a' };
}
const cookieValid = cookie ? Object.keys(videos).some(q => ORDER.indexOf(q) < ORDER.indexOf('720P')) || locked_qualities.length === 0 : null;
return {
success: true,
title: playurl.title || playurl.ep_title || data.data?.title || null,
duration: fmtDur(playurl.duration),
cookie_status: cookie ? (cookieValid ? 'valid' : 'invalid/expired') : 'none',
...(resolved !== input && { resolved_url: resolved }),
videos,
...(locked_qualities.length && { locked_qualities }),
audio: bestAudio ? { url:bestAudio.url, size:bestAudio.size, codec:bestAudio.codecs } : null,
headers: { Referer: REF }
};
}
function downloadFile(url, filename, headers) {
return new Promise(async (resolve, reject) => {
try {
const w = fs.createWriteStream(filename);
const r = await axios({ method:'get', url, responseType:'stream', headers:{ 'User-Agent':UA, ...headers }, timeout:0 });
const total = parseInt(r.headers['content-length']||'0', 10);
let done = 0;
r.data.on('data', c => { done += c.length; if (total) process.stdout.write('\r '+(done/total*100).toFixed(1)+'%'); });
r.data.pipe(w);
w.on('finish', () => { process.stdout.write('\n'); resolve(); });
w.on('error', reject);
} catch(e) { reject(e); }
});
}
function runFFmpeg(args) {
return new Promise((resolve, reject) => {
let err = '';
const ff = spawn('ffmpeg', args, { stdio:['ignore','ignore','pipe'] });
ff.stderr.on('data', d => { err += d.toString(); });
ff.on('close', c => c === 0 ? resolve() : reject(new Error('ffmpeg gagal:\n'+err.slice(-400))));
});
}
async function downloadAndMerge(result, quality) {
let stream = result.videos[quality];
if (!stream) {
const fallback = ORDER.find(q => result.videos[q]);
if (!fallback) { console.error('Tidak ada stream tersedia.'); process.exit(1); }
console.log(`Quality ${quality} tidak tersedia, fallback ke ${fallback}`);
quality = fallback; stream = result.videos[quality];
}
if (!result.audio) { console.error('Audio tidak ditemukan.'); process.exit(1); }
const safe = (result.title||'video').replace(/[<>:"/\\|?*\x00-\x1F]/g,'').slice(0,80).trim()||'video';
const vTmp = safe+'_'+quality+'_video.m4s', aTmp = safe+'_audio.m4s', out = safe+'_'+quality+'.mp4';
console.log('Title : '+(result.title||'-'));
console.log('Quality : '+quality+' ('+stream.codec+') - '+stream.size);
console.log('Audio : '+result.audio.size);
if (result.locked_qualities?.length) console.log('Locked : '+result.locked_qualities.join(', ')+' (butuh premium)');
console.log('');
console.log('Download video...'); await downloadFile(stream.url, vTmp, result.headers);
console.log('Download audio...'); await downloadFile(result.audio.url, aTmp, result.headers);
console.log('Merge...');
await runFFmpeg(['-y','-i',vTmp,'-i',aTmp,'-c','copy','-map','0:v:0','-map','1:a:0',out]);
try { fs.unlinkSync(vTmp); } catch(_) {}
try { fs.unlinkSync(aTmp); } catch(_) {}
console.log('Selesai → '+out);
}
async function main() {
const args = process.argv.slice(2);
if (!args.length) {
console.log(`bilibili.tv scraper + downloader
Penggunaan:
node bilibili.js <URL|ID|shortlink> info stream (JSON)
node bilibili.js <URL|ID|shortlink> -d download kualitas tertinggi
node bilibili.js <URL|ID|shortlink> -d <Q> download kualitas tertentu
Kualitas: 144P 240P 360P 480P 720P 1080P "1080P HD" "1080P 60FPS" 4K
Pakai tanda kutip untuk kualitas dengan spasi.
Cookie (untuk 1080P+ / premium):
--cookie "SESSDATA=xxx; bili_jct=yyy" via CLI
.env → BILIBILI_COOKIE=SESSDATA=xxx... via file`);
process.exit(1);
}
const input = args[0];
const isDownload = args.includes('-d');
const dIdx = args.indexOf('-d');
const cookieIdx = args.indexOf('--cookie');
const cookie = (cookieIdx !== -1 && args[cookieIdx+1]) ? args[cookieIdx+1] : (ENV.BILIBILI_COOKIE || '');
let quality = '4K';
if (dIdx !== -1 && args[dIdx+1] && !args[dIdx+1].startsWith('-')) {
quality = args[dIdx+1].toUpperCase().replace(/\s+/g,' ');
} else {
const qarg = args.find(a => /^(4K|1080P\s*(?:HD|60FPS)?|720P|480P|360P|240P|144P)$/i.test(a));
if (qarg) quality = qarg.toUpperCase().replace(/\s+/g,' ');
}
try {
const result = await getBilibiliTvStreams(input, { cookie });
if (isDownload) await downloadAndMerge(result, quality);
else console.log(JSON.stringify(result, null, 2));
} catch(e) {
console.error(JSON.stringify({ success:false, error:e.message }, null, 2));
process.exit(1);
}
}
if (import.meta.url === new URL(process.argv[1], 'file:').href) main();Scraper dan downloader untuk bilibili.tv (Bilibili International / Bstation). Support UGC, OGV/episode, short link, dan raw ID.
bashnpm install axios ffmpeg -version # pastikan ffmpeg tersedia
Tambahkan "type": "module" di package.json karena file ini menggunakan ESM.
bashnode bilibili.js <URL|ID|shortlink>
bashnode bilibili.js "https://www.bilibili.tv/id/video/4800343157513216" node bilibili.js "https://bili.im/WnPvKUr" node bilibili.js 4800343157513216
Output JSON berisi semua kualitas yang bisa diakses, ukuran file, codec, dan locked_qualities untuk kualitas yang terkunci (butuh premium).
bashnode bilibili.js <URL|ID|shortlink> -d [kualitas]
bashnode bilibili.js "https://bili.im/WnPvKUr" -d 4K node bilibili.js 4800343157513216 -d 1080P node bilibili.js "https://www.bilibili.tv/id/video/4800343157513216" -d
Jika kualitas tidak disebutkan, default ke kualitas tertinggi yang tersedia. Jika kualitas yang diminta tidak ada, otomatis fallback ke yang tertinggi.
| Flag | Keterangan |
|---|---|
144P | 144p |
240P | 240p |
360P | 360p |
480P | 480p |
720P | 720p — batas tertinggi tanpa cookie |
1080P | 1080p |
1080P HD | 1080p High Definition |
1080P 60FPS | 1080p 60fps |
4K | Ultra HD |
Untuk kualitas dengan spasi, gunakan tanda kutip: -d "1080P HD", -d "1080P 60FPS".
Berdasarkan pengujian langsung pada API:
Tanpa cookie — maksimal 720P. Kualitas di atasnya ada di response API tapi URL-nya dikosongkan server-side (locked_qualities di output JSON).
Dengan cookie login/premium — bisa akses hingga 4K tergantung konten. Cookie bisa dari akun biasa atau premium, tergantung konten yang di-lock.
Field cookie_status di output JSON:
none — tidak ada cookievalid — cookie aktif, kualitas premium terbukainvalid/expired — cookie ada tapi tetap dapat 720P sajabashnode bilibili.js <URL> -d 4K --cookie "SESSDATA=xxx; bili_jct=yyy"
.envBuat file .env di folder yang sama dengan script:
envBILIBILI_COOKIE=SESSDATA=xxx%2C...; bili_jct=yyy
Lalu jalankan seperti biasa tanpa --cookie. Priority: --cookie arg > .env.
Cookie bisa didapat dari browser setelah login ke bilibili.tv — DevTools → Application → Cookies → www.bilibili.tv.
URL stream dari bilibili.tv bersifat signed dan membutuhkan header Referer: https://www.bilibili.tv/. Tanpa header tersebut:
curl tanpa header: file terdownload tapi corrupt — karena video dan audio disimpan terpisah (format .m4s), bukan satu file MP4 utuhFlag -d menangani semua ini: download video + audio dengan header yang benar, lalu merge otomatis via ffmpeg menjadi .mp4 yang siap diputar.
bili.im · b23.tv · b23.app · b23.wtf · b23.tf · b23.icu · b23bb.tv · bili2233.cn · bili2233.ch · bstation.app
https://www.bilibili.tv/id/video/4800343157513216 ← UGC https://www.bilibili.tv/en/video/4800343157513216 ← UGC (English) https://www.bilibili.tv/play/12345/67890 ← OGV / episode https://bili.im/WnPvKUr ← short link 4800343157513216 ← raw ID