350 lines
8.9 KiB
Go
350 lines
8.9 KiB
Go
package bilibili
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/iawia002/annie/config"
|
|
"github.com/iawia002/annie/downloader"
|
|
"github.com/iawia002/annie/extractors"
|
|
"github.com/iawia002/annie/parser"
|
|
"github.com/iawia002/annie/request"
|
|
"github.com/iawia002/annie/utils"
|
|
)
|
|
|
|
const (
|
|
bilibiliAPI = "https://interface.bilibili.com/v2/playurl?"
|
|
bilibiliBangumiAPI = "https://bangumi.bilibili.com/player/web_api/v2/playurl?"
|
|
bilibiliTokenAPI = "https://api.bilibili.com/x/player/playurl/token?"
|
|
)
|
|
|
|
const (
|
|
// BiliBili blocks keys from time to time.
|
|
// You can extract from the Android client or bilibiliPlayer.min.js
|
|
appKey = "iVGUTjsxvpLeuDCf"
|
|
secKey = "aHRmhWMLkdeMuILqORnYZocwMBpMEOdt"
|
|
)
|
|
|
|
const referer = "https://www.bilibili.com"
|
|
|
|
var utoken string
|
|
|
|
func genAPI(aid, cid int, bangumi bool, quality string, seasonType string) (string, error) {
|
|
var (
|
|
err error
|
|
baseAPIURL string
|
|
params string
|
|
)
|
|
if config.Cookie != "" && utoken == "" {
|
|
utoken, err = request.Get(
|
|
fmt.Sprintf("%said=%d&cid=%d", bilibiliTokenAPI, aid, cid),
|
|
referer,
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var t token
|
|
err = json.Unmarshal([]byte(utoken), &t)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if t.Code != 0 {
|
|
return "", fmt.Errorf("cookie error: %s", t.Message)
|
|
}
|
|
utoken = t.Data.Token
|
|
}
|
|
if bangumi {
|
|
// The parameters need to be sorted by name
|
|
// qn=0 flag makes the CDN address different every time
|
|
// quality=116(1080P 60) is the highest quality so far
|
|
params = fmt.Sprintf(
|
|
"appkey=%s&cid=%d&module=bangumi&otype=json&qn=%s&quality=%s&season_type=%s&type=",
|
|
appKey, cid, quality, quality, seasonType,
|
|
)
|
|
baseAPIURL = bilibiliBangumiAPI
|
|
} else {
|
|
params = fmt.Sprintf(
|
|
"appkey=%s&cid=%d&otype=json&qn=%s&quality=%s&type=",
|
|
appKey, cid, quality, quality,
|
|
)
|
|
baseAPIURL = bilibiliAPI
|
|
}
|
|
// bangumi utoken also need to put in params to sign, but the ordinary video doesn't need
|
|
api := fmt.Sprintf(
|
|
"%s%s&sign=%s", baseAPIURL, params, utils.Md5(params+secKey),
|
|
)
|
|
if !bangumi && utoken != "" {
|
|
api = fmt.Sprintf("%s&utoken=%s", api, utoken)
|
|
}
|
|
return api, nil
|
|
}
|
|
|
|
func genURL(durl []dURLData) ([]downloader.URL, int64) {
|
|
var size int64
|
|
urls := make([]downloader.URL, len(durl))
|
|
for index, data := range durl {
|
|
size += data.Size
|
|
urls[index] = downloader.URL{
|
|
URL: data.URL,
|
|
Size: data.Size,
|
|
Ext: "flv",
|
|
}
|
|
}
|
|
return urls, size
|
|
}
|
|
|
|
type bilibiliOptions struct {
|
|
url string
|
|
html string
|
|
bangumi bool
|
|
aid int
|
|
cid int
|
|
page int
|
|
subtitle string
|
|
}
|
|
|
|
func extractBangumi(url, html string) ([]downloader.Data, error) {
|
|
dataString := utils.MatchOneOf(html, `window.__INITIAL_STATE__=(.+?);\(function`)[1]
|
|
var data bangumiData
|
|
err := json.Unmarshal([]byte(dataString), &data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !config.Playlist {
|
|
options := bilibiliOptions{
|
|
url: url,
|
|
html: html,
|
|
bangumi: true,
|
|
aid: data.EpInfo.Aid,
|
|
cid: data.EpInfo.Cid,
|
|
}
|
|
return []downloader.Data{bilibiliDownload(options)}, nil
|
|
}
|
|
|
|
// handle bangumi playlist
|
|
needDownloadItems := utils.NeedDownloadList(len(data.EpList))
|
|
extractedData := make([]downloader.Data, len(needDownloadItems))
|
|
wgp := utils.NewWaitGroupPool(config.ThreadNumber)
|
|
dataIndex := 0
|
|
for index, u := range data.EpList {
|
|
if !utils.ItemInSlice(index+1, needDownloadItems) {
|
|
continue
|
|
}
|
|
wgp.Add()
|
|
id := u.EpID
|
|
if id == 0 {
|
|
id = u.ID
|
|
}
|
|
// html content can't be reused here
|
|
options := bilibiliOptions{
|
|
url: fmt.Sprintf("https://www.bilibili.com/bangumi/play/ep%d", id),
|
|
bangumi: true,
|
|
aid: u.Aid,
|
|
cid: u.Cid,
|
|
}
|
|
go func(index int, options bilibiliOptions, extractedData []downloader.Data) {
|
|
defer wgp.Done()
|
|
extractedData[index] = bilibiliDownload(options)
|
|
}(dataIndex, options, extractedData)
|
|
dataIndex++
|
|
}
|
|
wgp.Wait()
|
|
return extractedData, nil
|
|
}
|
|
|
|
func getMultiPageData(html string) (*multiPage, error) {
|
|
var data multiPage
|
|
multiPageDataString := utils.MatchOneOf(
|
|
html, `window.__INITIAL_STATE__=(.+?);\(function`,
|
|
)
|
|
if multiPageDataString == nil {
|
|
return &data, errors.New("this page has no playlist")
|
|
}
|
|
err := json.Unmarshal([]byte(multiPageDataString[1]), &data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &data, nil
|
|
}
|
|
|
|
func extractNormalVideo(url, html string) ([]downloader.Data, error) {
|
|
pageData, err := getMultiPageData(html)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !config.Playlist {
|
|
// handle URL that has a playlist, mainly for unified titles
|
|
// <h1> tag does not include subtitles
|
|
// bangumi doesn't need this
|
|
pageString := utils.MatchOneOf(url, `\?p=(\d+)`)
|
|
var p int
|
|
if pageString == nil {
|
|
// https://www.bilibili.com/video/av20827366/
|
|
p = 1
|
|
} else {
|
|
// https://www.bilibili.com/video/av20827366/?p=2
|
|
p, _ = strconv.Atoi(pageString[1])
|
|
}
|
|
|
|
if len(pageData.VideoData.Pages) < p || p < 1 {
|
|
return nil, extractors.ErrURLParseFailed
|
|
}
|
|
|
|
page := pageData.VideoData.Pages[p-1]
|
|
options := bilibiliOptions{
|
|
url: url,
|
|
html: html,
|
|
aid: pageData.Aid,
|
|
cid: page.Cid,
|
|
page: p,
|
|
}
|
|
// "part":"" or "part":"Untitled"
|
|
if page.Part == "Untitled" || len(pageData.VideoData.Pages) == 1 {
|
|
options.subtitle = ""
|
|
} else {
|
|
options.subtitle = page.Part
|
|
}
|
|
return []downloader.Data{bilibiliDownload(options)}, nil
|
|
}
|
|
|
|
// handle normal video playlist
|
|
// https://www.bilibili.com/video/av20827366/?p=1
|
|
needDownloadItems := utils.NeedDownloadList(len(pageData.VideoData.Pages))
|
|
extractedData := make([]downloader.Data, len(needDownloadItems))
|
|
wgp := utils.NewWaitGroupPool(config.ThreadNumber)
|
|
dataIndex := 0
|
|
for index, u := range pageData.VideoData.Pages {
|
|
if !utils.ItemInSlice(index+1, needDownloadItems) {
|
|
continue
|
|
}
|
|
wgp.Add()
|
|
options := bilibiliOptions{
|
|
url: url,
|
|
html: html,
|
|
aid: pageData.Aid,
|
|
cid: u.Cid,
|
|
subtitle: u.Part,
|
|
page: u.Page,
|
|
}
|
|
go func(index int, options bilibiliOptions, extractedData []downloader.Data) {
|
|
defer wgp.Done()
|
|
extractedData[index] = bilibiliDownload(options)
|
|
}(dataIndex, options, extractedData)
|
|
dataIndex++
|
|
}
|
|
wgp.Wait()
|
|
return extractedData, nil
|
|
}
|
|
|
|
// Extract is the main function for extracting data
|
|
func Extract(url string) ([]downloader.Data, error) {
|
|
var err error
|
|
html, err := request.Get(url, referer, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.Contains(url, "bangumi") {
|
|
// handle bangumi
|
|
return extractBangumi(url, html)
|
|
}
|
|
// handle normal video
|
|
return extractNormalVideo(url, html)
|
|
}
|
|
|
|
// bilibiliDownload is the download function for a single URL
|
|
func bilibiliDownload(options bilibiliOptions) downloader.Data {
|
|
var (
|
|
err error
|
|
html string
|
|
seasonType string
|
|
)
|
|
if options.html != "" {
|
|
// reuse html string, but this can't be reused in case of playlist
|
|
html = options.html
|
|
} else {
|
|
html, err = request.Get(options.url, referer, nil)
|
|
if err != nil {
|
|
return downloader.EmptyData(options.url, err)
|
|
}
|
|
}
|
|
if options.bangumi {
|
|
seasonType = utils.MatchOneOf(html, `"season_type":(\d+)`, `"ssType":(\d+)`)[1]
|
|
}
|
|
|
|
// Get "accept_quality" and "accept_description"
|
|
// "accept_description":["高清 1080P","高清 720P","清晰 480P","流畅 360P"],
|
|
// "accept_quality":[80,48,32,16],
|
|
api, err := genAPI(options.aid, options.cid, options.bangumi, "15", seasonType)
|
|
if err != nil {
|
|
return downloader.EmptyData(options.url, err)
|
|
}
|
|
jsonString, err := request.Get(api, referer, nil)
|
|
if err != nil {
|
|
return downloader.EmptyData(options.url, err)
|
|
}
|
|
var quality qualityInfo
|
|
err = json.Unmarshal([]byte(jsonString), &quality)
|
|
if err != nil {
|
|
return downloader.EmptyData(options.url, err)
|
|
}
|
|
|
|
streams := make(map[string]downloader.Stream, len(quality.Quality))
|
|
for _, q := range quality.Quality {
|
|
apiURL, err := genAPI(options.aid, options.cid, options.bangumi, strconv.Itoa(q), seasonType)
|
|
if err != nil {
|
|
return downloader.EmptyData(options.url, err)
|
|
}
|
|
jsonString, err := request.Get(apiURL, referer, nil)
|
|
if err != nil {
|
|
return downloader.EmptyData(options.url, err)
|
|
}
|
|
var data bilibiliData
|
|
err = json.Unmarshal([]byte(jsonString), &data)
|
|
if err != nil {
|
|
return downloader.EmptyData(options.url, err)
|
|
}
|
|
|
|
// Avoid duplicate streams
|
|
if _, ok := streams[strconv.Itoa(data.Quality)]; ok {
|
|
continue
|
|
}
|
|
|
|
urls, size := genURL(data.DURL)
|
|
streams[strconv.Itoa(data.Quality)] = downloader.Stream{
|
|
URLs: urls,
|
|
Size: size,
|
|
Quality: qualityString[data.Quality],
|
|
}
|
|
}
|
|
|
|
// get the title
|
|
doc, err := parser.GetDoc(html)
|
|
if err != nil {
|
|
return downloader.EmptyData(options.url, err)
|
|
}
|
|
title := parser.Title(doc)
|
|
if options.subtitle != "" {
|
|
title = fmt.Sprintf("%s P%d %s", title, options.page, options.subtitle)
|
|
}
|
|
|
|
err = downloader.Caption(
|
|
fmt.Sprintf("https://comment.bilibili.com/%d.xml", options.cid),
|
|
options.url, title, "xml",
|
|
)
|
|
if err != nil {
|
|
return downloader.EmptyData(options.url, err)
|
|
}
|
|
|
|
return downloader.Data{
|
|
Site: "哔哩哔哩 bilibili.com",
|
|
Title: title,
|
|
Type: "video",
|
|
Streams: streams,
|
|
URL: options.url,
|
|
}
|
|
}
|