From 336665b1216611cac7f35d4765a7f62462dfedbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E6=AD=A3?= Date: Fri, 12 Apr 2024 14:13:12 +0800 Subject: [PATCH] =?UTF-8?q?[feature]=20#41=20=E5=AE=9E=E7=8E=B0=20WBI=20?= =?UTF-8?q?=E7=AD=BE=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client.go | 7 +- go.mod | 1 + go.sum | 4 +- wbi.go | 303 ++++++++++++++++++++++++++++++++++++++++++++++++++++ wbi_test.go | 57 ++++++++++ 5 files changed, 369 insertions(+), 3 deletions(-) create mode 100644 wbi.go create mode 100644 wbi_test.go diff --git a/client.go b/client.go index 734c219..5703b05 100644 --- a/client.go +++ b/client.go @@ -1,10 +1,11 @@ package bilibili import ( - "github.com/go-resty/resty/v2" "net/http" "strings" "time" + + "github.com/go-resty/resty/v2" ) type Client struct { @@ -50,6 +51,10 @@ func (c *Client) SetCookiesString(cookiesString string) { }}}).Cookies()) } +func (c *Client) GetCookies() []*http.Cookie { + return c.resty.Cookies +} + // 根据key获取指定的cookie值 func (c *Client) getCookie(name string) string { now := time.Now() diff --git a/go.mod b/go.mod index 38c82bd..f3c9656 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/pkg/errors v0.9.1 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/spf13/cast v1.6.0 + golang.org/x/sync v0.7.0 ) require ( diff --git a/go.sum b/go.sum index 536086d..6152151 100644 --- a/go.sum +++ b/go.sum @@ -36,13 +36,14 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -53,7 +54,6 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/wbi.go b/wbi.go new file mode 100644 index 0000000..3eacc88 --- /dev/null +++ b/wbi.go @@ -0,0 +1,303 @@ +package bilibili + +import ( + "crypto/md5" + "encoding/hex" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/go-resty/resty/v2" + "github.com/pkg/errors" + "golang.org/x/sync/singleflight" +) + +const ( + cacheImgKey = "imgKey" + cacheSubKey = "subKey" +) + +var ( + _defaultMixinKeyEncTab = []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, + } + + _defaultStorage = &MemoryStorage{ + data: make(map[string]interface{}, 15), + } +) + +type Storage interface { + Set(key string, value interface{}) + Get(key string) (v interface{}, isSet bool) +} + +type MemoryStorage struct { + data map[string]interface{} + mu sync.RWMutex +} + +func (impl *MemoryStorage) Set(key string, value interface{}) { + impl.mu.Lock() + defer impl.mu.Unlock() + + impl.data[key] = value +} + +func (impl *MemoryStorage) Get(key string) (v interface{}, isSet bool) { + impl.mu.RLock() + defer impl.mu.RUnlock() + + if v, isSet = impl.data[key]; isSet { + return v, true + } + return nil, false +} + +// WBI 签名实现 +// 如果希望以登录的方式获取则使用 WithCookies or WithRawCookies 设置cookie +// 如果希望以未登录的方式获取 WithCookies(nil) 设置cookie为 nil 即可, 这是 Default 行为 +// +// !!! 使用 WBI 的接口 绝对不可以 set header Referer 会导致失败 !!! +// !!! 大部分使用 WBI 的接口都需要 set header Cookie !!! +// +// see https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/misc/sign/wbi.md +type WBI struct { + cookies []*http.Cookie + mixinKeyEncTab []int + + // updateCheckerInterval is the interval to check and update wbi keys + // default is 60 minutes. so if lastInitTime + updateCheckerInterval < now, it will update wbi keys + updateCheckerInterval time.Duration + lastInitTime time.Time + storage Storage + + sfg singleflight.Group +} + +func NewDefaultWbi() *WBI { + return &WBI{ + cookies: nil, + mixinKeyEncTab: _defaultMixinKeyEncTab, + + updateCheckerInterval: 60 * time.Minute, + storage: _defaultStorage, + } +} + +func (wbi *WBI) WithUpdateInterval(updateInterval time.Duration) *WBI { + wbi.updateCheckerInterval = updateInterval + return wbi +} + +func (wbi *WBI) WithCookies(cookies []*http.Cookie) *WBI { + wbi.cookies = cookies + return wbi +} + +func (wbi *WBI) WithRawCookies(rawCookies string) *WBI { + header := http.Header{} + header.Add("Cookie", rawCookies) + req := http.Request{Header: header} + + wbi.cookies = req.Cookies() + return wbi +} + +func (wbi *WBI) WithMixinKeyEncTab(mixinKeyEncTab []int) *WBI { + wbi.mixinKeyEncTab = mixinKeyEncTab + return wbi +} + +func (wbi *WBI) WithStorage(storage Storage) *WBI { + wbi.storage = storage + return wbi +} + +func (wbi *WBI) GetKeys() (imgKey string, subKey string, err error) { + imgKey, subKey = wbi.getKeys() + + // 更新检查 + if imgKey == "" || subKey == "" || time.Since(wbi.lastInitTime) > wbi.updateCheckerInterval { + if err = wbi.initWbi(); err != nil { + return "", "", err + } + + return wbi.GetKeys() + } + + return imgKey, subKey, nil +} + +func (wbi *WBI) getKeys() (imgKey string, subKey string) { + if v, isSet := wbi.storage.Get(cacheImgKey); isSet { + imgKey = v.(string) + } + + if v, isSet := wbi.storage.Get(cacheSubKey); isSet { + subKey = v.(string) + } + + return imgKey, subKey +} + +func (wbi *WBI) SetKeys(imgKey, subKey string) { + wbi.storage.Set(cacheImgKey, imgKey) + wbi.storage.Set(cacheSubKey, subKey) + wbi.lastInitTime = time.Now() +} + +func (wbi *WBI) GetMixinKey() (string, error) { + imgKey, subKey, err := wbi.GetKeys() + if err != nil { + return "", err + } + + return wbi.GenerateMixinKey(imgKey + subKey), nil +} + +func (wbi *WBI) GenerateMixinKey(orig string) string { + var str strings.Builder + for _, v := range wbi.mixinKeyEncTab { + if v < len(orig) { + str.WriteByte(orig[v]) + } + } + return str.String()[:32] +} + +func (wbi *WBI) sanitizeString(s string) string { + unwantedChars := []string{"!", "'", "(", ")", "*"} + for _, char := range unwantedChars { + s = strings.ReplaceAll(s, char, "") + } + return s +} + +func (wbi *WBI) SignQuery(query url.Values, ts time.Time) (newQuery url.Values, err error) { + payload := make(map[string]string, 10) + for k := range query { + payload[k] = query.Get(k) + + } + + newPayload, err := wbi.SignMap(payload, ts) + if err != nil { + return query, err + } + + newQuery = url.Values{} + for k, v := range newPayload { + newQuery.Set(k, v) + } + + return newQuery, nil +} + +func (wbi *WBI) SignMap(payload map[string]string, ts time.Time) (newPayload map[string]string, err error) { + newPayload = make(map[string]string, 10) + for k, v := range payload { + newPayload[k] = v + } + + newPayload["wts"] = strconv.FormatInt(ts.Unix(), 10) + + // Sort keys + keys := make([]string, 0, 10) + for k := range newPayload { + keys = append(keys, k) + } + + sort.Strings(keys) + + // Remove unwanted characters + for k, v := range newPayload { + v = wbi.sanitizeString(v) + newPayload[k] = v + } + + // Build URL parameters + signQuery := url.Values{} + for _, k := range keys { + signQuery.Set(k, newPayload[k]) + } + signQueryStr := signQuery.Encode() + + // Get mixin key + mixinKey, err := wbi.GetMixinKey() + if err != nil { + return payload, err + } + + // Calculate w_rid + hash := md5.Sum([]byte(signQueryStr + mixinKey)) + newPayload["w_rid"] = hex.EncodeToString(hash[:]) + + return newPayload, nil +} + +func (wbi *WBI) initWbi() error { + _, err, _ := wbi.sfg.Do("initWbi", func() (interface{}, error) { + return nil, wbi.doInitWbi() + }) + + if err != nil { + return err + } + + return nil +} + +func (wbi *WBI) doInitWbi() error { + result := struct { + Code int `json:"code"` + Message string `json:"message"` + Data struct { + WbiImg struct { + ImgUrl string `json:"img_url"` + SubUrl string `json:"sub_url"` + } `json:"wbi_img"` + } + }{} + + resp, err := resty.New().R(). + SetHeader("Accept", "application/json"). + SetHeader("Accept-Language", "zh-CN,zh;q=0.9"). + SetHeader("Origin", "https://www.bilibili.com"). + SetHeader("Referer", "https://www.bilibili.com/"). + SetHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0"). + SetCookies(wbi.cookies). + SetResult(&result). + Get("https://api.bilibili.com/x/web-interface/nav") + + if err != nil { + return errors.WithStack(err) + } + if resp.StatusCode() != http.StatusOK { + return errors.Errorf("status code: %d", resp.StatusCode()) + } + + if result.Code != 0 { + if result.Data.WbiImg.ImgUrl == "" || result.Data.WbiImg.SubUrl == "" { + return errors.Errorf("init wbi 失败, 错误码: %d, 错误信息: %s", result.Code, result.Message) + } + } + + if len(resp.Cookies()) > 0 { + // update cookie + wbi.cookies = resp.Cookies() + } + + imgKey := strings.Split(strings.Split(result.Data.WbiImg.ImgUrl, "/")[len(strings.Split(result.Data.WbiImg.ImgUrl, "/"))-1], ".")[0] + subKey := strings.Split(strings.Split(result.Data.WbiImg.SubUrl, "/")[len(strings.Split(result.Data.WbiImg.SubUrl, "/"))-1], ".")[0] + + wbi.SetKeys(imgKey, subKey) + return nil +} diff --git a/wbi_test.go b/wbi_test.go new file mode 100644 index 0000000..c41f93d --- /dev/null +++ b/wbi_test.go @@ -0,0 +1,57 @@ +package bilibili + +import ( + "fmt" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" +) + +const ckRaw = `buvid3=B767D3D5-9EBF-29D3-2EF7-29CB3A3311CE68136infoc; b_nut=1712902068; b_lsid=9D8D5A55_18ED0EB5314; _uuid=114E10D14-6E98-59A1-72B8-810C26F5DF2F866968infoc; enable_web_push=DISABLE; buvid_fp=c983ea9e89578d4cacec0fa7685013ab; buvid4=66CC1819-26AF-6887-611A-3491E061709C68718-024041206-2Q6xaryFLHEwY9bCN8w5oA%3D%3D; home_feed_column=4; browser_resolution=1082-1271; FEED_LIVE_VERSION=V_WATCHLATER_PIP_WINDOW2; header_theme_version=CLOSE` + +func TestWbiSignQuery(t *testing.T) { + rURL, err := url.Parse("https://api.bilibili.com/x/space/wbi/acc/info?mid=1850091") + if err != nil { + fmt.Printf("Error: %s", err) + return + } + + wbi := NewDefaultWbi() + + q, err := wbi.SignQuery(rURL.Query(), time.Now()) + if err != nil { + fmt.Printf("Error: %s", err) + return + } + + rURL.RawQuery = q.Encode() + + req, err := http.NewRequest("GET", rURL.String(), nil) + if err != nil { + fmt.Printf("Error: %s", err) + return + } + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") + //req.Header.Add("Referer", "https://www.bilibili.com/") // 设置会导致失败 + req.Header.Add("Cookie", ckRaw) // 不设置会导致失败 + response, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + return + } + + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + return + } + + if strings.Contains(string(body), "风控校验失败") { + t.Fatal("风控校验失败") + return + } +}