mirror of
https://github.com/the1812/Bilibili-Evolved.git
synced 2025-11-04 21:22:45 +08:00
333 lines
11 KiB
TypeScript
333 lines
11 KiB
TypeScript
import { formatTitle } from '../title'
|
|
|
|
const fragmentSplitFactor = 12
|
|
const dashExtensions = ['.mp4', '.m4a']
|
|
|
|
export interface BatchExtractorConfig {
|
|
itemFilter: (item: BatchItem) => boolean
|
|
api?: (aid: number | string, cid: number | string, quality?: number | string) => string
|
|
}
|
|
export interface BatchTitleParameter {
|
|
[key: string]: string
|
|
n: string
|
|
ep: string
|
|
}
|
|
interface BatchItem {
|
|
aid: number | string
|
|
cid: number | string
|
|
titleParameters?: BatchTitleParameter
|
|
title: string
|
|
}
|
|
export interface RawItemFragment { length: number, size: number, url: string }
|
|
export interface RawItem {
|
|
fragments: RawItemFragment[]
|
|
title: string
|
|
totalSize: number
|
|
cid: number | string
|
|
referer: string
|
|
}
|
|
abstract class Batch {
|
|
constructor(public config: BatchExtractorConfig) { }
|
|
itemList: BatchItem[] = []
|
|
abstract async getItemList(): Promise<BatchItem[]>
|
|
abstract async collectData(quality: number | string): Promise<string>
|
|
static formatTitle(parameters: BatchTitleParameter | undefined) {
|
|
const format = settings.batchFilenameFormat
|
|
const title = formatTitle(format, true, parameters)
|
|
return escapeFilename(title, ' ')
|
|
}
|
|
async getRawItems(quality: number | string): Promise<RawItem[]> {
|
|
return JSON.parse(await this.collectData(quality))
|
|
}
|
|
extension(url: string, index: number) {
|
|
const match = [
|
|
'.flv',
|
|
'.mp4'
|
|
].find(it => url.includes(it))
|
|
if (match) {
|
|
return match
|
|
} else if (url.includes('.m4s')) {
|
|
return dashExtensions[index]
|
|
} else {
|
|
return '.flv'
|
|
}
|
|
}
|
|
async collectAria2(quality: number | string, rpc: boolean) {
|
|
const json = await this.getRawItems(quality)
|
|
if (rpc) {
|
|
const option = settings.aria2RpcOption
|
|
const { sendRpc } = await import('./aria2-rpc')
|
|
for (const item of json) {
|
|
const params = item.fragments.map((fragment: { url: string }, index: number) => {
|
|
let indexNumber = ''
|
|
if (item.fragments.length > 1 && !fragment.url.includes('.m4s')) {
|
|
indexNumber = ' - ' + (index + 1)
|
|
}
|
|
const params = []
|
|
if (option.secretKey !== '') {
|
|
params.push(`token:${option.secretKey}`)
|
|
}
|
|
params.push([fragment.url])
|
|
params.push({
|
|
referer: document.URL.replace(window.location.search, ''),
|
|
'user-agent': UserAgent,
|
|
out: `${item.title}${indexNumber}${this.extension(fragment.url, index)}`,
|
|
split: fragmentSplitFactor,
|
|
dir: (option.baseDir + option.dir) || undefined,
|
|
'max-download-limit': option.maxDownloadLimit || undefined,
|
|
})
|
|
const id = encodeURIComponent(`${item.title}${indexNumber}`)
|
|
return {
|
|
params,
|
|
id,
|
|
}
|
|
})
|
|
await sendRpc(params, true)
|
|
}
|
|
} else {
|
|
return `
|
|
# Generated by Bilibili Evolved Video Export
|
|
# https://github.com/the1812/Bilibili-Evolved/
|
|
${json.map(item => {
|
|
return item.fragments.map((f, index) => {
|
|
let indexNumber = ''
|
|
if (item.fragments.length > 1 && !f.url.includes('.m4s')) {
|
|
indexNumber = ` - ${index + 1}`
|
|
}
|
|
return `
|
|
${f.url}
|
|
referer=${item.referer}
|
|
user-agent=${UserAgent}
|
|
out=${item.title}${indexNumber}${this.extension(f.url, index)}
|
|
split=${fragmentSplitFactor}
|
|
`.trim()
|
|
}).join('\n')
|
|
}).join('\n')}
|
|
`.trim()
|
|
}
|
|
}
|
|
}
|
|
class VideoEpisodeBatch extends Batch {
|
|
aid = unsafeWindow.aid!
|
|
static async test() {
|
|
if (document.URL.includes('//www.bilibili.com/video/av')) {
|
|
return await SpinQuery.select('#multi_page') !== null
|
|
}
|
|
return false
|
|
}
|
|
async getItemList() {
|
|
if (this.itemList.length > 0) {
|
|
return this.itemList
|
|
}
|
|
const api = `https://api.bilibili.com/x/web-interface/view?aid=${this.aid}`
|
|
const json = await Ajax.getJson(api)
|
|
if (json.code !== 0) {
|
|
Toast.error(`获取视频选集列表失败, message=${json.message}`, '批量下载')
|
|
return []
|
|
}
|
|
const pages = json.data.pages
|
|
if (pages === undefined) {
|
|
Toast.error(`获取视频选集列表失败, 没有找到选集信息.`, '批量下载')
|
|
return []
|
|
}
|
|
this.itemList = pages.map((page: any) => {
|
|
return {
|
|
title: `P${page.page} ${page.part}`,
|
|
titleParameters: {
|
|
n: page.page,
|
|
ep: page.part
|
|
},
|
|
cid: page.cid,
|
|
aid: this.aid,
|
|
} as BatchItem
|
|
})
|
|
return this.itemList
|
|
}
|
|
async collectData(quality: number | string) {
|
|
const result = []
|
|
for (const item of (await this.getItemList()).filter(this.config.itemFilter)) {
|
|
const url = this.config.api ? this.config.api(item.aid, item.cid, quality) : `https://api.bilibili.com/x/player/playurl?avid=${item.aid}&cid=${item.cid}&qn=${quality}&otype=json`
|
|
const json = await Ajax.getJsonWithCredentials(url)
|
|
const data = json.data || json.result || json
|
|
if (data.quality !== quality) {
|
|
console.warn(`${item.title} 不支持所选画质, 已回退到较低画质. (quality=${data.quality})`)
|
|
}
|
|
let fragments: RawItemFragment[]
|
|
if (data.durl) {
|
|
fragments = data.durl.map((it: any) => {
|
|
return {
|
|
length: it.length,
|
|
size: it.size,
|
|
url: it.url
|
|
}
|
|
})
|
|
} else {
|
|
const { getDashInfo, dashToFragments } = await import('./video-dash')
|
|
const info = await getDashInfo(url, typeof quality === 'string' ? parseInt(quality) : quality)
|
|
fragments = dashToFragments(info)
|
|
}
|
|
result.push({
|
|
fragments,
|
|
// title: item.title.replace(/[\/\\:\*\?"<>\|]/g, ' '),
|
|
title: Batch.formatTitle(item.titleParameters),
|
|
totalSize: fragments.map(it => it.size).reduce((acc, it) => acc + it),
|
|
cid: item.cid,
|
|
referer: document.URL.replace(window.location.search, '')
|
|
})
|
|
}
|
|
return JSON.stringify(result)
|
|
}
|
|
}
|
|
class Bnj2020Batch extends Batch {
|
|
mainVideo: VideoEpisodeBatch
|
|
spVideo: VideoEpisodeBatch
|
|
constructor(config: BatchExtractorConfig) {
|
|
super(config)
|
|
// 拜年祭就硬编码 aid 了(
|
|
this.mainVideo = new VideoEpisodeBatch(config)
|
|
this.mainVideo.aid = '78976165'
|
|
this.spVideo = new VideoEpisodeBatch(config)
|
|
this.spVideo.aid = '78979124'
|
|
}
|
|
static async test() {
|
|
return document.URL.includes('//www.bilibili.com/blackboard/bnj2020.html')
|
|
}
|
|
async getItemList() {
|
|
return (await this.mainVideo.getItemList()).concat(await this.spVideo.getItemList())
|
|
}
|
|
async collectData(quality: string | number) {
|
|
return (await this.mainVideo.collectData(quality)).concat(await this.spVideo.collectData(quality))
|
|
}
|
|
}
|
|
class BangumiBatch extends Batch {
|
|
static async test() {
|
|
return document.URL.includes('/www.bilibili.com/bangumi')
|
|
}
|
|
async getItemList() {
|
|
if (this.itemList.length > 0) {
|
|
return this.itemList
|
|
}
|
|
const metaUrl = document.querySelector("meta[property='og:url']")
|
|
if (metaUrl === null) {
|
|
Toast.error('获取番剧数据失败: 无法找到 Season ID', '批量下载')
|
|
return []
|
|
}
|
|
const seasonId = metaUrl.getAttribute('content')!.match(/play\/ss(\d+)/)![1]
|
|
if (seasonId === undefined) {
|
|
Toast.error('获取番剧数据失败: 无法解析 Season ID', '批量下载')
|
|
return []
|
|
}
|
|
const json = await Ajax.getJson(`https://api.bilibili.com/pgc/web/season/section?season_id=${seasonId}`)
|
|
if (json.code !== 0) {
|
|
Toast.error(`获取番剧数据失败: 无法获取番剧集数列表, message=${json.message}`, '批量下载')
|
|
return []
|
|
}
|
|
this.itemList = json.result.main_section.episodes.map((it: any, index: number) => {
|
|
const n: string = it.long_title ? it.title : (index + 1).toString()
|
|
const title: string = it.long_title ? it.long_title : it.title
|
|
return {
|
|
aid: it.aid,
|
|
cid: it.cid,
|
|
title: `${n} - ${title}`,
|
|
// title: it.long_title ? `${it.title} - ${it.long_title}` : `${index + 1} - ${it.title}`,
|
|
titleParameters: {
|
|
n,
|
|
ep: title,
|
|
},
|
|
} as BatchItem
|
|
})
|
|
return this.itemList
|
|
}
|
|
async collectData(quality: string | number) {
|
|
const result = []
|
|
for (const item of (await this.getItemList()).filter(this.config.itemFilter)) {
|
|
const url = this.config.api ? this.config.api(item.aid, item.cid, quality) : `https://api.bilibili.com/pgc/player/web/playurl?avid=${item.aid}&cid=${item.cid}&qn=${quality}&otype=json`
|
|
const json = await Ajax.getJsonWithCredentials(url)
|
|
const data = json.data || json.result || json
|
|
if (data.quality !== quality) {
|
|
console.warn(`${item.title} 不支持所选画质, 已回退到较低画质. (quality=${data.quality})`)
|
|
}
|
|
let fragments: RawItemFragment[]
|
|
if (data.durl) {
|
|
fragments = data.durl.map((it: any) => {
|
|
return {
|
|
length: it.length,
|
|
size: it.size,
|
|
url: it.url
|
|
}
|
|
})
|
|
} else {
|
|
const { getDashInfo, dashToFragments } = await import('./video-dash')
|
|
const info = await getDashInfo(url, typeof quality === 'string' ? parseInt(quality) : quality)
|
|
fragments = dashToFragments(info)
|
|
}
|
|
result.push({
|
|
fragments,
|
|
// title: item.title.replace(/[\/\\:\*\?"<>\|]/g, ' '),
|
|
title: Batch.formatTitle(item.titleParameters),
|
|
totalSize: fragments.map(it => it.size).reduce((acc, it) => acc + it),
|
|
cid: item.cid,
|
|
referer: document.URL.replace(window.location.search, '')
|
|
})
|
|
}
|
|
return JSON.stringify(result)
|
|
}
|
|
}
|
|
const extractors = [BangumiBatch, VideoEpisodeBatch, Bnj2020Batch]
|
|
let ExtractorClass: new (config: BatchExtractorConfig) => Batch
|
|
export class BatchExtractor {
|
|
config: BatchExtractorConfig
|
|
constructor(config?: BatchExtractorConfig) {
|
|
this.config = Object.assign({
|
|
itemFilter: () => true,
|
|
}, config)
|
|
}
|
|
static async test() {
|
|
for (const e of extractors) {
|
|
if (await e.test() === true) {
|
|
ExtractorClass = e
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
getExtractor() {
|
|
if (ExtractorClass === null) {
|
|
logError('[批量下载] 未找到合适的解析模块.')
|
|
throw new Error(`[Batch Download] module not found.`)
|
|
}
|
|
const extractor = new ExtractorClass(this.config)
|
|
return extractor
|
|
}
|
|
async getItemList() {
|
|
const extractor = this.getExtractor()
|
|
return await extractor.getItemList()
|
|
}
|
|
async getRawItems(format: { quality: number | string }) {
|
|
const extractor = this.getExtractor()
|
|
return await extractor.getRawItems(format.quality)
|
|
}
|
|
async collectData(format: { quality: number | string }, toast: Toast) {
|
|
const extractor = this.getExtractor()
|
|
const result = await extractor.collectData(format.quality)
|
|
toast.dismiss()
|
|
return result
|
|
}
|
|
async collectAria2(format: { quality: number | string }, toast: Toast, rpc: false): Promise<string>
|
|
async collectAria2(format: { quality: number | string }, toast: Toast, rpc: true): Promise<undefined>
|
|
async collectAria2(format: { quality: number | string }, toast: Toast, rpc = false) {
|
|
const extractor = this.getExtractor()
|
|
const result = await extractor.collectAria2(format.quality, rpc)
|
|
toast.dismiss()
|
|
return result
|
|
}
|
|
formatTitle(parameters: BatchTitleParameter | undefined) {
|
|
return Batch.formatTitle(parameters)
|
|
}
|
|
}
|
|
export default {
|
|
export: {
|
|
BatchExtractor
|
|
}
|
|
}
|