refactor: restructure project to standard Go layout
- Reorganize code into cmd/bilidown and internal/ packages - Rename client/ to web/ for frontend source - Remove systray dependency for headless web service - Embed static files into binary using go:embed - Update import paths to use internal/ prefix - Update .gitignore with common patterns Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
package bilibili_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bilidown/bilibili"
|
||||
"bilidown/util"
|
||||
|
||||
"github.com/skip2/go-qrcode"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const TEST_SESSDATA = ""
|
||||
const TEST_BVID = "BV1KX4y1V7sA" // 普通合集
|
||||
const TEST_CID = 305542578 // 普通分集
|
||||
const TEST_BVID_HDR = "BV1rp4y1e745" // HDR 合集
|
||||
const TEST_CID_HDR = 244954665 // HDR 分集
|
||||
const TEST_EPID = 835909 // 剧集
|
||||
const TEST_SSID = 48744 // 番剧
|
||||
|
||||
func TestBiliClient(t *testing.T) {
|
||||
client := bilibili.BiliClient{SESSDATA: TEST_SESSDATA}
|
||||
check, err := client.CheckLogin()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if check {
|
||||
fmt.Println("SESSDATA 有效")
|
||||
} else {
|
||||
fmt.Println("SESSDATA 无效")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewQRInfo(t *testing.T) {
|
||||
client := bilibili.BiliClient{}
|
||||
info, err := client.NewQRInfo()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
fmt.Printf("二维码信息:%+v", info)
|
||||
}
|
||||
|
||||
func TestGetQRStatus(t *testing.T) {
|
||||
client := bilibili.BiliClient{}
|
||||
qrInfo, err := client.NewQRInfo()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
qr, err := qrcode.New(qrInfo.URL, qrcode.Low)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
fmt.Println(qr.ToSmallString(false))
|
||||
|
||||
for {
|
||||
qrStatus, sessdata, err := client.GetQRStatus(qrInfo.QrcodeKey)
|
||||
fmt.Println(qrStatus.Message)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if qrStatus.Code == bilibili.QR_SUCCESS {
|
||||
fmt.Println("登录成功")
|
||||
fmt.Println(sessdata)
|
||||
break
|
||||
} else if qrStatus.Code == bilibili.QR_EXPIRES {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBVInfo(t *testing.T) {
|
||||
client := bilibili.BiliClient{SESSDATA: TEST_SESSDATA}
|
||||
videoInfo, err := client.GetVideoInfo(TEST_BVID)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
fmt.Printf("%+v", videoInfo)
|
||||
}
|
||||
|
||||
func TestGetSeasonInfo(t *testing.T) {
|
||||
client := bilibili.BiliClient{SESSDATA: TEST_SESSDATA}
|
||||
seasonInfo, err := client.GetSeasonInfo(TEST_EPID, TEST_SSID)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
fmt.Printf("%+v", seasonInfo)
|
||||
}
|
||||
|
||||
func TestGetPlayInfo(t *testing.T) {
|
||||
client := bilibili.BiliClient{SESSDATA: TEST_SESSDATA}
|
||||
playInfo, err := client.GetPlayInfo(TEST_BVID_HDR, TEST_CID_HDR)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
a, _ := json.Marshal(playInfo.AcceptQuality)
|
||||
b, _ := json.Marshal(playInfo.AcceptDescription)
|
||||
c, _ := json.Marshal(playInfo.SupportFormats)
|
||||
|
||||
fmt.Println(string(a))
|
||||
fmt.Println(string(b))
|
||||
fmt.Println(string(c))
|
||||
}
|
||||
|
||||
func TestSaveSessdata(t *testing.T) {
|
||||
db := util.MustGetDB("../data.db")
|
||||
defer db.Close()
|
||||
err := bilibili.SaveSessdata(db, TEST_SESSDATA)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
} else {
|
||||
fmt.Println("SESSDATA 保存成功")
|
||||
}
|
||||
sessdata, err := bilibili.GetSessdata(db)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if sessdata != TEST_SESSDATA {
|
||||
t.Error("SESSDATA 读写不一致")
|
||||
} else {
|
||||
fmt.Println("SESSDATA 读取成功")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSessdata(t *testing.T) {
|
||||
db := util.MustGetDB("../data.db")
|
||||
defer db.Close()
|
||||
sessdata, err := bilibili.GetSessdata(db)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
fmt.Println(sessdata)
|
||||
}
|
||||
|
||||
func TestGetPopularVideos(t *testing.T) {
|
||||
client := bilibili.BiliClient{SESSDATA: TEST_SESSDATA}
|
||||
videos, err := client.GetPopularVideos()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
fmt.Printf("%+v", videos)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package bilibili
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
|
||||
"bilidown/internal/util"
|
||||
)
|
||||
|
||||
type BiliClient struct {
|
||||
SESSDATA string
|
||||
}
|
||||
|
||||
// SimpleGET 简单的 GET 请求
|
||||
func (client *BiliClient) SimpleGET(_url string, params map[string]string) (*http.Response, error) {
|
||||
values := url.Values{}
|
||||
for k, v := range params {
|
||||
values.Set(k, v)
|
||||
}
|
||||
_client := http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(nil),
|
||||
},
|
||||
}
|
||||
request, err := http.NewRequest("GET", _url+"?"+values.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header = client.MakeHeader()
|
||||
return _client.Do(request)
|
||||
}
|
||||
|
||||
// MakeHeader 生成请求头
|
||||
func (client *BiliClient) MakeHeader() http.Header {
|
||||
header := http.Header{}
|
||||
header.Set("Cookie", "SESSDATA="+client.SESSDATA)
|
||||
header.Set("User-Agent", "Mozilla/5.0")
|
||||
header.Set("Referer", "https://www.bilibili.com")
|
||||
return header
|
||||
}
|
||||
|
||||
// CheckLogin 检查是否已经登录
|
||||
func (client *BiliClient) CheckLogin() (bool, error) {
|
||||
response, err := client.SimpleGET("https://api.bilibili.com/x/space/myinfo", nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body := BaseResV2{}
|
||||
err = json.NewDecoder(response.Body).Decode(&body)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if body.Code != 0 {
|
||||
return false, errors.New(body.Message)
|
||||
}
|
||||
return body.Success(), nil
|
||||
}
|
||||
|
||||
// NewQRInfo 获取登录二维码信息
|
||||
func (client *BiliClient) NewQRInfo() (*QRInfo, error) {
|
||||
response, err := client.SimpleGET("https://passport.bilibili.com/x/passport-login/web/qrcode/generate", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body := BaseResV2{}
|
||||
err = json.NewDecoder(response.Body).Decode(&body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body.Code != 0 {
|
||||
return nil, errors.New(body.Message)
|
||||
}
|
||||
qrInfo := QRInfo{}
|
||||
err = json.Unmarshal(body.Data, &qrInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &qrInfo, nil
|
||||
}
|
||||
|
||||
func (client *BiliClient) getWbiKeyRemote() (wbiKey string, err error) {
|
||||
if client.SESSDATA == "" {
|
||||
return "", errors.New("SESSDATA 不能为空")
|
||||
}
|
||||
response, err := client.SimpleGET("https://api.bilibili.com/x/web-interface/nav", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body := BaseResV2{}
|
||||
if err = json.NewDecoder(response.Body).Decode(&body); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var data struct {
|
||||
WbiImg struct {
|
||||
ImgURL string `json:"img_url"`
|
||||
SubURL string `json:"sub_url"`
|
||||
} `json:"wbi_img"`
|
||||
}
|
||||
if err = json.Unmarshal(body.Data, &data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
match := regexp.MustCompile(`/bfs/wbi/([a-z0-9]+)\.`)
|
||||
imgKey := match.FindStringSubmatch(data.WbiImg.ImgURL)[1]
|
||||
subKey := match.FindStringSubmatch(data.WbiImg.SubURL)[1]
|
||||
if imgKey == "" || subKey == "" {
|
||||
return "", errors.New("regexp.MustCompile(`/bfs/wbi/([a-z0-9])\\.`)")
|
||||
}
|
||||
return imgKey + subKey, nil
|
||||
}
|
||||
|
||||
// GetQRStatus 获取二维码状态
|
||||
func (client *BiliClient) GetQRStatus(qrKey string) (qrStatus *QRStatus, sessdata string, err error) {
|
||||
params := map[string]string{
|
||||
"qrcode_key": qrKey,
|
||||
}
|
||||
response, err := client.SimpleGET("https://passport.bilibili.com/x/passport-login/web/qrcode/poll", params)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body := BaseResV2{}
|
||||
err = json.NewDecoder(response.Body).Decode(&body)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if body.Code != 0 {
|
||||
return nil, "", errors.New(body.Message)
|
||||
}
|
||||
qrStatus = &QRStatus{}
|
||||
err = json.Unmarshal(body.Data, &qrStatus)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if qrStatus.Code != 0 {
|
||||
return qrStatus, "", nil
|
||||
}
|
||||
sessdata, err = GetCookieValue(response.Cookies(), "SESSDATA")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return qrStatus, sessdata, nil
|
||||
}
|
||||
|
||||
// GetCookieValue 获取指定 Name 的 Cookie 值
|
||||
func GetCookieValue(cookies []*http.Cookie, name string) (string, error) {
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == name {
|
||||
return cookie.Value, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("cookie with name " + name + " not found")
|
||||
}
|
||||
|
||||
// SaveSessdata 保存 SESSDATA
|
||||
func SaveSessdata(db *sql.DB, sessdata string) error {
|
||||
util.SqliteLock.Lock()
|
||||
_, err := db.Exec(`INSERT OR REPLACE INTO "field" ("name", "value") VALUES ("sessdata", ?)`, sessdata)
|
||||
util.SqliteLock.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
// GetSessdata 获取 SESSDATA
|
||||
func GetSessdata(db *sql.DB) (string, error) {
|
||||
util.SqliteLock.Lock()
|
||||
row := db.QueryRow(`SELECT "value" FROM "field" WHERE "name" = "sessdata"`)
|
||||
util.SqliteLock.Unlock()
|
||||
var sessdata string
|
||||
err := row.Scan(&sessdata)
|
||||
return sessdata, err
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package bilibili
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"bilidown/internal/common"
|
||||
)
|
||||
|
||||
// BaseRes 来自 Bilibili 的接口响应,Message 字段为 msg
|
||||
type BaseRes struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"msg"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
// BaseRes2 来自 Bilibili 的接口响应,Message 字段为 message 而不是 msg
|
||||
type BaseResV2 struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
// BaseResV3 来自 Bilibili 的接口响应,Message 字段为 message 而不是 msg,Data 字段为 result
|
||||
type BaseResV3 struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
}
|
||||
|
||||
func (b *BaseRes) Success() bool {
|
||||
return b.Code == 0
|
||||
}
|
||||
|
||||
func (b *BaseResV2) Success() bool {
|
||||
return b.Code == 0
|
||||
}
|
||||
|
||||
func (b *BaseResV3) Success() bool {
|
||||
return b.Code == 0
|
||||
}
|
||||
|
||||
type QRInfo struct {
|
||||
URL string `json:"url"`
|
||||
QrcodeKey string `json:"qrcode_key"`
|
||||
}
|
||||
|
||||
type QRStatus struct {
|
||||
URL string `json:"string"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
const (
|
||||
QR_NO_SCAN = 86101 // 未扫码
|
||||
QR_NO_CLICK = 86090 // 已扫码未确认
|
||||
QR_EXPIRES = 86038 // 已过期
|
||||
QR_SUCCESS = 0 // 已确认登录
|
||||
)
|
||||
|
||||
// Dimension 视频分辨率
|
||||
type Dimension struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Rotate int `json:"rotate"`
|
||||
}
|
||||
|
||||
// StaffItem 视频人员
|
||||
type StaffItem struct {
|
||||
Mid int `json:"mid"`
|
||||
Title string `json:"title"`
|
||||
Name string `json:"name"`
|
||||
Face string `json:"face"`
|
||||
}
|
||||
|
||||
// Page 分集信息
|
||||
type Page struct {
|
||||
// 配合 Bvid 用于获取播放地址
|
||||
Cid int `json:"cid"`
|
||||
// 当前分集在合集中的序号,从 1 开始
|
||||
Page int `json:"page"`
|
||||
// 分集标题
|
||||
Part string `json:"part"`
|
||||
// 分集时长
|
||||
Duration int `json:"duration"`
|
||||
// 分集分辨率
|
||||
Dimension `json:"dimension"`
|
||||
}
|
||||
|
||||
// 通过 BVID 获取的视频信息
|
||||
type VideoInfo struct {
|
||||
Bvid string `json:"bvid"`
|
||||
Aid int `json:"aid"`
|
||||
Pic string `json:"pic"`
|
||||
Title string `json:"title"`
|
||||
Pubdate int `json:"pubdate"`
|
||||
Desc string `json:"desc"`
|
||||
Owner struct {
|
||||
Mid int `json:"mid"`
|
||||
Name string `json:"name"`
|
||||
Face string `json:"face"`
|
||||
} `json:"owner"`
|
||||
Dimension `json:"dimension"`
|
||||
Staff []StaffItem `json:"staff"`
|
||||
Pages []Page `json:"pages"`
|
||||
Duration int `json:"duration"`
|
||||
Stat struct {
|
||||
Aid int `json:"aid"`
|
||||
View int `json:"view"` // 播放数量
|
||||
Danmaku int `json:"danmaku"` // 弹幕数量
|
||||
Reply int `json:"reply"` // 评论数量
|
||||
Favorite int `json:"favorite"` // 收藏数量
|
||||
Coin int `json:"coin"` // 投币数量
|
||||
Share int `json:"share"` // 转发数量
|
||||
NowRank int `json:"now_rank"` // 当前排名
|
||||
HisRank int `json:"his_rank"` // 历史最高排名
|
||||
Like int `json:"like"` // 点赞数量
|
||||
Dislike int `json:"dislike"` // 不喜欢
|
||||
} `json:"stat"`
|
||||
UgcSeason struct {
|
||||
Sections []struct {
|
||||
Title string `json:"title"`
|
||||
Episodes []struct {
|
||||
Title string `json:"title"`
|
||||
Pages []Page `json:"pages"`
|
||||
Bvid string `json:"bvid"`
|
||||
} `json:"episodes"`
|
||||
} `json:"sections"`
|
||||
Title string `json:"title"`
|
||||
} `json:"ugc_season"`
|
||||
}
|
||||
|
||||
// EpisodeInBV 通过 BV 获取的视频信息中的合集信息
|
||||
type EpisodeInBV struct {
|
||||
Aid int `json:"aid"`
|
||||
Bvid string `json:"bvid"`
|
||||
Cid int `json:"cid"`
|
||||
Pic string `json:"pic"` // 封面
|
||||
Dimension `json:"dimension"` // 分辨率
|
||||
Duration int `json:"duration"` // 时长
|
||||
EPID int `json:"ep_id"`
|
||||
LongTitle string `json:"long_title"` // 分集完整标题,比如【法外狂徒张三现身!】
|
||||
PubTime int `json:"pub_time"` // 发布时间
|
||||
Title string `json:"title"` // 分集简略标题,比如【1】
|
||||
}
|
||||
|
||||
// Episode 剧集分集信息
|
||||
type Episode struct {
|
||||
Aid int `json:"aid"`
|
||||
Bvid string `json:"bvid"`
|
||||
Cid int `json:"cid"`
|
||||
Cover string `json:"cover"` // 封面
|
||||
Dimension `json:"dimension"` // 分辨率
|
||||
Duration int `json:"duration"` // 时长
|
||||
EPID int `json:"ep_id"`
|
||||
LongTitle string `json:"long_title"` // 分集完整标题,比如【法外狂徒张三现身!】
|
||||
PubTime int `json:"pub_time"` // 发布时间
|
||||
Title string `json:"title"` // 分集简略标题,比如【1】
|
||||
}
|
||||
|
||||
// 通过 EPID 获取的视频信息
|
||||
type SeasonInfo struct {
|
||||
Actors string `json:"actors"` // 演员名单
|
||||
Areas []struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"areas"` // 地区列表
|
||||
Cover string `json:"cover"` // 封面
|
||||
Evaluate string `json:"evaluate"` // 简介
|
||||
Publish struct {
|
||||
IsFinish int `json:"is_finish"` // 是否完结
|
||||
PubTime string `json:"pub_time"` // 发布时间
|
||||
} `json:"publish"`
|
||||
SeasonID int `json:"season_id"` // 剧集编号
|
||||
SeasonTitle string `json:"season_title"` // 剧集标题
|
||||
Stat struct {
|
||||
Coins int `json:"coins"` // 投币数量
|
||||
Danmakus int `json:"danmakus"` // 弹幕数量
|
||||
Favorite int `json:"favorite"` // 收藏数量
|
||||
Favorites int `json:"favorites"` // 追剧数量
|
||||
Likes int `json:"likes"` // 点赞数量
|
||||
Reply int `json:"reply"` // 评论数量
|
||||
Share int `json:"share"` // 分享数量
|
||||
Views int `json:"views"` // 播放数量
|
||||
} `json:"stat"`
|
||||
Styles []string `json:"styles"` // 剧集内容类型,例如 [ "短剧", "奇幻", "搞笑" ]
|
||||
Title string `json:"title"` // 剧集标题
|
||||
Total int `json:"total"` // 总集数
|
||||
|
||||
Episodes []Episode `json:"episodes"` // 分集信息列表
|
||||
|
||||
NewEp struct {
|
||||
Desc string `json:"desc"` // 更新状态文本
|
||||
IsNew int `json:"is_new"` // 是否是连载,0 为完结,1 为连载
|
||||
} `json:"new_ep"`
|
||||
|
||||
Section []struct {
|
||||
Title string `json:"title"`
|
||||
Episodes []Episode `json:"episodes"`
|
||||
} `json:"section"`
|
||||
}
|
||||
|
||||
type PlayInfo struct {
|
||||
AcceptDescription []string `json:"accept_description"`
|
||||
AcceptQuality []common.MediaFormat `json:"accept_quality"`
|
||||
SupportFormats []struct {
|
||||
Quality common.MediaFormat `json:"quality"`
|
||||
Format string `json:"format"`
|
||||
NewDescription string `json:"new_description"`
|
||||
Codecs []string `json:"codecs"`
|
||||
} `json:"support_formats"`
|
||||
Dash *Dash `json:"dash"`
|
||||
}
|
||||
|
||||
type Dash struct {
|
||||
// 视频时长(秒)
|
||||
Duration int `json:"duration"`
|
||||
Video []Media `json:"video"`
|
||||
Audio []Media `json:"audio"`
|
||||
Flac *struct {
|
||||
Audio Media `json:"audio"`
|
||||
} `json:"flac"`
|
||||
}
|
||||
|
||||
type Media struct {
|
||||
ID common.MediaFormat `json:"id"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
BackupURL []string `json:"backupUrl"`
|
||||
Bandwidth int `json:"bandwidth"`
|
||||
MimeType string `json:"mimeType"`
|
||||
Codecs string `json:"codecs"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
FrameRate string `json:"frameRate"`
|
||||
Codecid int `json:"codecid"`
|
||||
}
|
||||
|
||||
type FavList []struct {
|
||||
Title string `json:"title"`
|
||||
Cover string `json:"cover"`
|
||||
Intro string `json:"intro"`
|
||||
Duration int `json:"duration"`
|
||||
Upper struct {
|
||||
Mid int `json:"mid"`
|
||||
Name string `json:"name"`
|
||||
Face string `json:"face"`
|
||||
} `json:"upper"`
|
||||
PubTime int `json:"pubtime"`
|
||||
Bvid string `json:"bvid"`
|
||||
Ugc struct {
|
||||
FirstCid int `json:"first_cid"`
|
||||
} `json:"ugc"`
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package bilibili
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GetBVInfo 根据 BVID 获取视频信息
|
||||
func (client *BiliClient) GetVideoInfo(bvid string) (*VideoInfo, error) {
|
||||
if client.SESSDATA == "" {
|
||||
return nil, errors.New("SESSDATA 不能为空")
|
||||
}
|
||||
params := map[string]string{"bvid": bvid}
|
||||
response, err := client.SimpleGET("https://api.bilibili.com/x/web-interface/wbi/view", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := BaseResV2{}
|
||||
err = json.NewDecoder(response.Body).Decode(&body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body.Code != 0 {
|
||||
return nil, errors.New(body.Message)
|
||||
}
|
||||
bvInfo := VideoInfo{}
|
||||
err = json.Unmarshal(body.Data, &bvInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &bvInfo, nil
|
||||
}
|
||||
|
||||
// GetSeasonInfo 根据 EPID 或 SSID 获取剧集信息
|
||||
func (client *BiliClient) GetSeasonInfo(epid int, ssid int) (*SeasonInfo, error) {
|
||||
if client.SESSDATA == "" {
|
||||
return nil, errors.New("SESSDATA 不能为空")
|
||||
}
|
||||
params := map[string]string{
|
||||
"ep_id": strconv.Itoa(epid),
|
||||
"season_id": strconv.Itoa(ssid),
|
||||
}
|
||||
response, err := client.SimpleGET("https://api.bilibili.com/pgc/view/web/season", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := BaseResV3{}
|
||||
err = json.NewDecoder(response.Body).Decode(&body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body.Code != 0 {
|
||||
return nil, errors.New(body.Message)
|
||||
}
|
||||
seasonInfo := SeasonInfo{}
|
||||
err = json.Unmarshal(body.Result, &seasonInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &seasonInfo, nil
|
||||
}
|
||||
|
||||
// GetPlayInfo 根据 BVID 和 CID 获取视频播放信息
|
||||
func (client *BiliClient) GetPlayInfo(bvid string, cid int) (*PlayInfo, error) {
|
||||
if client.SESSDATA == "" {
|
||||
return nil, errors.New("SESSDATA 不能为空")
|
||||
}
|
||||
params := map[string]string{
|
||||
"bvid": bvid,
|
||||
"cid": strconv.Itoa(cid),
|
||||
"fnval": "4048",
|
||||
"fnver": "0",
|
||||
"fourk": "1",
|
||||
}
|
||||
response, err := client.SimpleGET("https://api.bilibili.com/x/player/playurl", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := BaseResV2{}
|
||||
err = json.NewDecoder(response.Body).Decode(&body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body.Code != 0 {
|
||||
return nil, errors.New(body.Message)
|
||||
}
|
||||
playInfo := PlayInfo{}
|
||||
err = json.Unmarshal(body.Data, &playInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &playInfo, nil
|
||||
}
|
||||
|
||||
func (client *BiliClient) GetPopularVideos() ([]VideoInfo, error) {
|
||||
if client.SESSDATA == "" {
|
||||
return nil, errors.New("SESSDATA 不能为空")
|
||||
}
|
||||
urls := []string{
|
||||
"https://api.bilibili.com/x/web-interface/popular",
|
||||
"https://api.bilibili.com/x/web-interface/popular/precious",
|
||||
"https://api.bilibili.com/x/web-interface/ranking/v2",
|
||||
}
|
||||
response, err := client.SimpleGET(urls[rand.Intn(len(urls))], nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body := BaseResV2{}
|
||||
|
||||
err = json.NewDecoder(response.Body).Decode(&body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body.Code != 0 {
|
||||
return nil, errors.New(body.Message)
|
||||
}
|
||||
data := struct {
|
||||
List []VideoInfo `json:"list"`
|
||||
}{}
|
||||
|
||||
err = json.Unmarshal(body.Data, &data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data.List, nil
|
||||
}
|
||||
|
||||
// GetSeasonsArchivesList 获取合集中的第一个视频的 BVID
|
||||
func (client *BiliClient) GetSeasonsArchivesListFirstBvid(mid int, seasonId int) (string, error) {
|
||||
url := "https://api.bilibili.com/x/polymer/web-space/seasons_archives_list"
|
||||
params := map[string]string{
|
||||
"mid": strconv.Itoa(mid),
|
||||
"season_id": strconv.Itoa(seasonId),
|
||||
"page_num": "1",
|
||||
"page_size": "1",
|
||||
}
|
||||
|
||||
response, err := client.SimpleGET(url, params)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
body := BaseResV2{}
|
||||
if err = json.NewDecoder(response.Body).Decode(&body); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if body.Code != 0 {
|
||||
return "", errors.New(body.Message)
|
||||
}
|
||||
var data struct {
|
||||
Archives []struct {
|
||||
Bvid string `json:"bvid"`
|
||||
} `json:"archives"`
|
||||
}
|
||||
if err = json.Unmarshal(body.Data, &data); err != nil {
|
||||
return "", nil
|
||||
}
|
||||
if len(data.Archives) == 0 {
|
||||
return "", errors.New("视频列表为空")
|
||||
}
|
||||
return data.Archives[0].Bvid, nil
|
||||
}
|
||||
|
||||
func (client *BiliClient) GetFavlist(mediaId int) (*FavList, error) {
|
||||
if client.SESSDATA == "" {
|
||||
return nil, errors.New("SESSDATA 不能为空")
|
||||
}
|
||||
page := 0
|
||||
retry := 0
|
||||
allFavList := FavList{}
|
||||
for {
|
||||
favList, hasMore, err := client.GetFavlistByPage(mediaId, page, 40)
|
||||
if err != nil {
|
||||
fmt.Println(err.Error())
|
||||
if retry == 5 || strings.HasPrefix(err.Error(), "body.Code not 0") {
|
||||
return nil, err
|
||||
}
|
||||
retry++
|
||||
continue
|
||||
}
|
||||
allFavList = append(allFavList, *favList...)
|
||||
if !hasMore {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
return &allFavList, nil
|
||||
}
|
||||
|
||||
func (client *BiliClient) GetFavlistByPage(mediaId int, page int, pageSize int) (favlist *FavList, hasMore bool, err error) {
|
||||
if client.SESSDATA == "" {
|
||||
return nil, false, errors.New("SESSDATA 不能为空")
|
||||
}
|
||||
response, err := client.SimpleGET("https://api.bilibili.com/x/v3/fav/resource/list", map[string]string{
|
||||
"media_id": strconv.Itoa(mediaId),
|
||||
"pn": strconv.Itoa(page + 1),
|
||||
"ps": strconv.Itoa(pageSize),
|
||||
"order": "mtime",
|
||||
"type": "0",
|
||||
"tid": "0",
|
||||
"platform": "web",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body := BaseResV2{}
|
||||
if err = json.NewDecoder(response.Body).Decode(&body); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if body.Code != 0 {
|
||||
return nil, false, fmt.Errorf("body.Code not 0, %s", body.Message)
|
||||
}
|
||||
data := struct {
|
||||
Medias FavList `json:"medias"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}{}
|
||||
if err = json.Unmarshal(body.Data, &data); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return &data.Medias, data.HasMore, nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package bilibili
|
||||
|
||||
import (
|
||||
"bilidown/internal/util"
|
||||
"database/sql"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var MIXIN_KEY_ENC_TAB = []int{
|
||||
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35,
|
||||
27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13,
|
||||
37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4,
|
||||
22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52,
|
||||
}
|
||||
|
||||
// GetWebKey 获取最新可用的 WbiKey,自动刷新缓存
|
||||
func (client *BiliClient) getWbiKey(db *sql.DB) (wbiKey string, err error) {
|
||||
wbiKey, err = getWbiKeyFromDB(db)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if wbiKey == "" {
|
||||
// 更新数据库中的 WebKey
|
||||
wbiKey, err = client.getWbiKeyRemote()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err = saveWbiKey(db, wbiKey); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return wbiKey, nil
|
||||
}
|
||||
|
||||
func (client *BiliClient) GetMixinKey(db *sql.DB) (string, error) {
|
||||
wbiKey, err := client.getWbiKey(db)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var result string
|
||||
for _, index := range MIXIN_KEY_ENC_TAB {
|
||||
result += string(wbiKey[index])
|
||||
}
|
||||
return result[:32], nil
|
||||
}
|
||||
|
||||
func WbiSign(params map[string]string, mixinKey string) url.Values {
|
||||
values := url.Values{}
|
||||
for key, value := range params {
|
||||
values.Set(key, value)
|
||||
}
|
||||
wts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
values.Set("wts", wts)
|
||||
encodeStr := strings.ReplaceAll(values.Encode(), "+", "%20") + mixinKey
|
||||
w_rid := util.MD5Hash(encodeStr)
|
||||
values.Set("w_rid", w_rid)
|
||||
return values
|
||||
}
|
||||
|
||||
// GetWbiKey 从数据库中获取 wbiKey,如果数据库中不存在记录或记录过期,则返回空字符串但不返回错误
|
||||
func getWbiKeyFromDB(db *sql.DB) (wbiKey string, err error) {
|
||||
fields, err := util.GetFields(db, "wbi_key", "wbi_key_update_at")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 获取上次更新时间的时间戳,单位是秒
|
||||
updateAt, err := strconv.ParseInt(fields["wbi_key_update_at"], 10, 64)
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
// 注意 key_update_at 单位是秒,判断上次刷新时间是否超过 1 天
|
||||
if time.Now().Unix()-updateAt > 24*60*60 {
|
||||
return "", nil
|
||||
}
|
||||
return fields["wbi_key"], nil
|
||||
}
|
||||
|
||||
// SaveWbiKey 保存 imgKey 和 subKey 到数据库
|
||||
func saveWbiKey(db *sql.DB, wbiKey string) error {
|
||||
return util.SaveFields(db, [][2]string{
|
||||
{"wbi_key", wbiKey},
|
||||
{"wbi_key_update_at", strconv.FormatInt(time.Now().Unix(), 10)},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package bilibili
|
||||
|
||||
import (
|
||||
"bilidown/util"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGetWbiKey(t *testing.T) {
|
||||
db := util.MustGetDB("../data.db")
|
||||
defer db.Close()
|
||||
sessdata, err := GetSessdata(db)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
client := BiliClient{SESSDATA: sessdata}
|
||||
mixinKey, err := client.GetMixinKey(db)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
fmt.Println(mixinKey)
|
||||
}
|
||||
|
||||
func TestTime(t *testing.T) {
|
||||
fmt.Println(time.Now().Unix())
|
||||
}
|
||||
|
||||
func TestURLEncode(t *testing.T) {
|
||||
str := url.Values{
|
||||
"foo": {"one one four"},
|
||||
"bar": {"五一四"},
|
||||
"baz": {"1919810"},
|
||||
}.Encode()
|
||||
str = strings.ReplaceAll(str, "+", "%20")
|
||||
fmt.Println(str)
|
||||
}
|
||||
|
||||
func TestWbiSign(t *testing.T) {
|
||||
db := util.MustGetDB("../data.db")
|
||||
defer db.Close()
|
||||
sessdata, err := GetSessdata(db)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
client := BiliClient{SESSDATA: sessdata}
|
||||
mixinKey, err := client.GetMixinKey(db)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
newParams := WbiSign(map[string]string{
|
||||
"dm_cover_img_str": "QU5HTEUgKEludGVsLCBJbnRlbChSKSBJcmlzKFIpIFhlIEdyYXBoaWNzICgweDAwMDA5QTQ5KSBEaXJlY3QzRDExIHZzXzVfMCBwc181XzAsIEQzRDExKUdvb2dsZSBJbmMuIChJbnRlbC",
|
||||
"dm_img_inter": `{"ds":[{"t":10,"c":"YmUtcGFnZXItaXRlbSBiZS1wYWdlci1pdGVtLWFjdGl2ZQ","p":[330,110,110],"s":[179,179,358]}],"wh":[5762,6704,10],"of":[318,636,318]}`,
|
||||
"dm_img_list": `[{"x":1847,"y":-1616,"z":0,"timestamp":2486463,"k":70,"type":0},{"x":1972,"y":-1005,"z":24,"timestamp":2486563,"k":120,"type":0},{"x":2564,"y":-638,"z":72,"timestamp":2486665,"k":107,"type":0},{"x":4200,"y":-753,"z":199,"timestamp":2486765,"k":91,"type":0},{"x":4922,"y":-73,"z":426,"timestamp":2486865,"k":106,"type":0},{"x":4656,"y":-512,"z":35,"timestamp":2486965,"k":94,"type":0},{"x":4942,"y":-590,"z":79,"timestamp":2487066,"k":126,"type":0},{"x":5200,"y":-414,"z":123,"timestamp":2487167,"k":60,"type":0},{"x":4916,"y":-820,"z":228,"timestamp":2563922,"k":111,"type":0},{"x":5004,"y":-50,"z":570,"timestamp":2564022,"k":110,"type":0},{"x":4784,"y":330,"z":666,"timestamp":2564122,"k":98,"type":0},{"x":5029,"y":767,"z":956,"timestamp":2564223,"k":115,"type":0},{"x":5327,"y":1400,"z":1307,"timestamp":2564324,"k":107,"type":0},{"x":4598,"y":671,"z":578,"timestamp":2564424,"k":125,"type":0},{"x":5228,"y":1283,"z":1193,"timestamp":2564525,"k":83,"type":0},{"x":5210,"y":932,"z":978,"timestamp":2564625,"k":66,"type":0},{"x":5034,"y":195,"z":484,"timestamp":2564725,"k":112,"type":0},{"x":6496,"y":1467,"z":1849,"timestamp":2564825,"k":83,"type":0},{"x":6592,"y":1387,"z":1852,"timestamp":2564929,"k":122,"type":0},{"x":6001,"y":728,"z":1235,"timestamp":2565030,"k":95,"type":0},{"x":6666,"y":1393,"z":1900,"timestamp":2627309,"k":121,"type":1},{"x":5486,"y":5603,"z":2283,"timestamp":2627842,"k":102,"type":0},{"x":5269,"y":4186,"z":1733,"timestamp":2627947,"k":117,"type":0},{"x":4439,"y":3174,"z":897,"timestamp":2628056,"k":117,"type":0},{"x":5231,"y":3959,"z":1687,"timestamp":2628268,"k":79,"type":0},{"x":6197,"y":4786,"z":2587,"timestamp":2628368,"k":96,"type":0},{"x":4667,"y":3298,"z":724,"timestamp":2628470,"k":91,"type":0},{"x":4658,"y":3436,"z":205,"timestamp":2629433,"k":88,"type":0},{"x":5380,"y":3443,"z":1117,"timestamp":2630390,"k":121,"type":0},{"x":5559,"y":2678,"z":1920,"timestamp":2630492,"k":116,"type":0},{"x":5517,"y":2465,"z":2023,"timestamp":2630634,"k":86,"type":0},{"x":6584,"y":3533,"z":3087,"timestamp":2630782,"k":96,"type":0},{"x":6183,"y":3242,"z":2839,"timestamp":2630892,"k":103,"type":0},{"x":6277,"y":580,"z":1449,"timestamp":2663911,"k":68,"type":0},{"x":7556,"y":3077,"z":3053,"timestamp":2664013,"k":114,"type":0},{"x":5321,"y":1642,"z":1201,"timestamp":2664114,"k":104,"type":0},{"x":5839,"y":2261,"z":1761,"timestamp":2664215,"k":72,"type":0},{"x":6303,"y":2726,"z":2222,"timestamp":2664317,"k":93,"type":0},{"x":7553,"y":4255,"z":3532,"timestamp":2664420,"k":104,"type":0},{"x":7677,"y":4381,"z":3650,"timestamp":2664521,"k":119,"type":0},{"x":8184,"y":4762,"z":4098,"timestamp":2664622,"k":122,"type":0},{"x":4555,"y":740,"z":406,"timestamp":2664722,"k":118,"type":0},{"x":4305,"y":241,"z":75,"timestamp":2664822,"k":105,"type":0},{"x":6373,"y":2218,"z":2117,"timestamp":2664934,"k":110,"type":0},{"x":6079,"y":1757,"z":1680,"timestamp":2665036,"k":90,"type":0},{"x":8158,"y":3462,"z":3524,"timestamp":2665136,"k":100,"type":0},{"x":9312,"y":4205,"z":4531,"timestamp":2665236,"k":124,"type":0},{"x":5306,"y":89,"z":487,"timestamp":2665337,"k":89,"type":0},{"x":9721,"y":4399,"z":4941,"timestamp":2665437,"k":100,"type":0},{"x":9058,"y":3727,"z":4282,"timestamp":2665540,"k":107,"type":0}]`,
|
||||
"dm_img_str": "V2ViR0wgMS4wIChPcGVuR0wgRVMgMi4wIENocm9taXVtKQ",
|
||||
"keyword": "",
|
||||
"mid": "596866446",
|
||||
"order": "pubdate",
|
||||
"order_avoided": "true",
|
||||
"platform": "web",
|
||||
"pn": "3",
|
||||
"ps": "30",
|
||||
"tid": "0",
|
||||
"w_webid": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzcG1faWQiOiIwLjAiLCJidXZpZCI6IjBGRTlGRDMxLTZGQTQtRjU2RC1GQjEwLUI4OTg2ODUyRUZDNzUwMDUyaW5mb2MiLCJ1c2VyX2FnZW50IjoiTW96aWxsYS81LjAgKFdpbmRvd3MgTlQgMTAuMDsgV2luNjQ7IHg2NCkgQXBwbGVXZWJLaXQvNTM3LjM2IChLSFRNTCwgbGlrZSBHZWNrbykgQ2hyb21lLzEzMS4wLjAuMCBTYWZhcmkvNTM3LjM2IEVkZy8xMzEuMC4wLjAiLCJidXZpZF9mcCI6IjY3YzZkNzg2NTgyYzQ2OGYwNjRiZTE5MTk2OWU4ZWE4IiwiYmlsaV90aWNrZXQiOiI4ODA0MDgzYmYzZGQ1NGNkNzI4OWZjZDE0MmIwN2U4ZCIsImNyZWF0ZWRfYXQiOjE3MzI0NzE3MTIsInR0bCI6ODY0MDAsInVybCI6Ii81OTY4NjY0NDYvdmlkZW8_dGlkPTBcdTAwMjZwbj0yXHUwMDI2a2V5d29yZD1cdTAwMjZvcmRlcj1wdWJkYXRlIiwicmVzdWx0Ijoibm9ybWFsIiwiaXNzIjoiZ2FpYSIsImlhdCI6MTczMjQ3MTcxMn0.pTVyhdRcB2VFcXqeYoi3TjnlEFLAXMpNNJK-dW9Gq3vqBL4YldGgZBuGBXXF7Ldwg_vW6TQg6pQCQ7vz357ws2Z9g2-kLIuNmR3j8oMg2zAXAND1q5oJNw0jNnevhLlB8_vOcip0eIJSHRjqbPbNShKLOcSnSfLaiI64EHwjRFAOEPYVw3evLXKB4TFnxSRi1WDSI684TfNrXp0_2yTJvuPheHQmQC1NcUP_P9tqTuRiDy3YfkuR8PlRcQxmKHVs-byObL5WEPqMMQa8b8zidRtzEkbGV7ra8gTgv1HwgpSDi_Y1VNsX2WtNcBPTSwURWc7zREf-RJqruzgcf1ErpA",
|
||||
"web_location": "1550101",
|
||||
}, mixinKey)
|
||||
|
||||
fmt.Println(newParams.Encode())
|
||||
}
|
||||
Reference in New Issue
Block a user