bilibili
bilibili/Bstation downloader tanpa watermark, support 720p+ langsung ke bstation/bilibili
bilibili.jsjavascript · 247 baris
#!/usr/bin/env nodeimport 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 + downloaderPenggunaan: 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 tertentuKualitas: 144P 240P 360P 480P 720P 1080P "1080P HD" "1080P 60FPS" 4KPakai 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();README.mdmarkdown · 147 baris
Komentar
Masuk untuk ikut berkomentar.
Belum ada komentar.