Files
EveryPublish/server/internal/platform/bilibili/bilibili.go
T

551 lines
17 KiB
Go

package bilibili
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"everypublish/server/internal/credentials"
"everypublish/server/internal/platform"
)
// Adapter implements the server-side Bilibili API boundary. The exported
// endpoint fields make the adapter straightforward to use with an httptest
// server while retaining production defaults in New.
type Adapter struct {
Store *credentials.Store
Credentials *credentials.Store // compatibility alias for callers using this name
HTTP *http.Client
MemberBase string
PassportBase string
UpOSScheme string
PollTimeout time.Duration
PollInterval time.Duration
MaxRetries int
}
func New(store *credentials.Store) *Adapter {
return &Adapter{Store: store, Credentials: store, HTTP: &http.Client{Timeout: 30 * time.Second},
MemberBase: "https://member.bilibili.com", PassportBase: "https://passport.bilibili.com",
UpOSScheme: "https", PollTimeout: 3 * time.Minute, PollInterval: 2 * time.Second, MaxRetries: 2}
}
func (a *Adapter) Platform() string { return "bilibili" }
func (a *Adapter) BeginLogin(ctx context.Context) (platform.LoginSession, error) {
var ret struct {
Code int `json:"code"`
Data struct {
URL string `json:"url"`
Key string `json:"qrcode_key"`
} `json:"data"`
}
if err := a.getJSON(ctx, a.passportBase()+"/x/passport-login/web/qrcode/generate", "", &ret); err != nil {
return platform.LoginSession{}, err
}
if ret.Code != 0 || ret.Data.Key == "" || ret.Data.URL == "" {
return platform.LoginSession{}, perr(platform.PlatformChanged, "invalid QR response", nil)
}
return platform.LoginSession{Token: ret.Data.Key, URL: ret.Data.URL}, nil
}
func (a *Adapter) PollLogin(ctx context.Context, token string) (platform.LoginPoll, error) {
if token == "" {
return platform.LoginPoll{}, perr(platform.Validation, "QR token is empty", nil)
}
q := url.Values{"qrcode_key": []string{token}}
var ret struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
URL string `json:"url"`
} `json:"data"`
}
if err := a.getJSON(ctx, a.passportBase()+"/x/passport-login/web/qrcode/poll?"+q.Encode(), "", &ret); err != nil {
return platform.LoginPoll{}, err
}
switch ret.Code {
case 86101:
return platform.LoginPoll{State: platform.LoginPending, Prompt: "请使用哔哩哔哩手机客户端扫码"}, nil
case 86090:
return platform.LoginPoll{State: platform.LoginScanned, Prompt: "已扫码,请在哔哩哔哩手机客户端确认登录"}, nil
case 0:
cookies := cookiesFromURL(ret.Data.URL)
if cookies == "" {
return platform.LoginPoll{}, perr(platform.PlatformChanged, "login response did not contain cookies", nil)
}
return platform.LoginPoll{State: platform.LoginConfirmed, Cookies: cookies}, nil
default:
return platform.LoginPoll{}, perr(platform.PlatformChanged, "QR poll rejected: "+ret.Message, nil)
}
}
func (a *Adapter) SaveCredentials(workspaceID, accountID uint64, cookies string) error {
if strings.TrimSpace(cookies) == "" {
return perr(platform.Validation, "cookies are empty", nil)
}
if cookieValue(cookies, "SESSDATA") == "" || cookieValue(cookies, "bili_jct") == "" || cookieValue(cookies, "DedeUserID") == "" {
return perr(platform.Validation, "Bilibili cookies must include SESSDATA, bili_jct and DedeUserID", nil)
}
s := a.store()
if s == nil {
return perr(platform.Unknown, "credential store is not configured", nil)
}
if err := s.Save(workspaceID, accountID, []byte(cookies)); err != nil {
return perr(platform.Unknown, "saving credentials", err)
}
return nil
}
func (a *Adapter) CheckCredentials(ctx context.Context, workspaceID, accountID uint64) error {
s := a.store()
if s == nil {
return perr(platform.Unknown, "credential store is not configured", nil)
}
raw, err := s.Load(workspaceID, accountID)
if err != nil {
return perr(platform.AuthRequired, "credentials are unavailable", err)
}
var ret struct {
Code int `json:"code"`
Data struct {
IsLogin bool `json:"isLogin"`
} `json:"data"`
}
if err := a.getJSON(ctx, a.memberBase()+"/x/web-interface/nav", string(raw), &ret); err != nil {
return err
}
if ret.Code != 0 || !ret.Data.IsLogin {
return perr(platform.AuthRequired, "Bilibili login has expired", nil)
}
return nil
}
func (a *Adapter) Publish(ctx context.Context, in platform.PublishInput) (platform.PublishResult, error) {
s := a.store()
if s == nil {
return platform.PublishResult{}, perr(platform.Unknown, "credential store is not configured", nil)
}
raw, err := s.Load(in.WorkspaceID, in.AccountID)
if err != nil {
return platform.PublishResult{}, perr(platform.AuthRequired, "credentials are unavailable", err)
}
if len(in.MaterialURLs) == 0 {
return platform.PublishResult{}, perr(platform.Validation, "material is required", nil)
}
if len(in.MaterialURLs) > 1 {
return platform.PublishResult{}, perr(platform.Validation, "Bilibili currently accepts one video per task", nil)
}
path, cleanup, err := a.download(ctx, in.MaterialURLs[0])
if err != nil {
return platform.PublishResult{}, err
}
defer cleanup()
part, err := a.upload(ctx, string(raw), path)
if err != nil {
return platform.PublishResult{}, err
}
bvid, err := a.submit(ctx, string(raw), in, part)
if err != nil {
return platform.PublishResult{}, err
}
if bvid == "" {
return platform.PublishResult{}, perr(platform.PlatformChanged, "submission did not return bvid", nil)
}
return platform.PublishResult{URL: "https://www.bilibili.com/video/" + bvid, Receipt: bvid}, nil
}
func (a *Adapter) download(ctx context.Context, rawURL string) (string, func(), error) {
raw, err := a.request(ctx, http.MethodGet, rawURL, "", nil, nil)
if err != nil {
return "", nil, err
}
f, err := os.CreateTemp("", "ep-bilibili-*")
if err != nil {
return "", nil, perr(platform.Unknown, "creating temporary material", err)
}
name := f.Name()
if _, err = f.Write(raw); err != nil {
f.Close()
os.Remove(name)
return "", nil, perr(platform.Network, "saving material", err)
}
if err = f.Close(); err != nil {
os.Remove(name)
return "", nil, perr(platform.Network, "saving material", err)
}
return name, func() { _ = os.Remove(name) }, nil
}
func (a *Adapter) upload(ctx context.Context, cookies, path string) (map[string]interface{}, error) {
st, err := os.Stat(path)
if err != nil {
return nil, perr(platform.Validation, "material unavailable", err)
}
name, total := filepath.Base(path), st.Size()
if total <= 0 {
return nil, perr(platform.Validation, "material is empty", nil)
}
q := url.Values{"r": {"upos"}, "profile": {"ugcupos/bup"}, "ssl": {"0"}, "version": {"2.8.12"}, "build": {"2081200"}, "name": {name}, "size": {strconv.FormatInt(total, 10)}}
var pre struct {
OK int `json:"OK"`
Endpoint string `json:"endpoint"`
Auth string `json:"auth"`
BizID int `json:"biz_id"`
UposURI string `json:"upos_uri"`
ChunkSize int `json:"chunk_size"`
}
if err := a.getJSON(ctx, a.memberBase()+"/preupload?upcdn=bda2&probe_version=20221109&"+q.Encode(), cookies, &pre); err != nil {
return nil, err
}
if pre.OK != 1 || pre.Endpoint == "" || pre.UposURI == "" {
return nil, perr(platform.PlatformChanged, "invalid preupload response", nil)
}
upURL := a.upURL(pre.Endpoint, pre.UposURI)
var up struct {
UploadID string `json:"upload_id"`
}
if err := a.postJSON(ctx, upURL+"?uploads&output=json", cookies, map[string]string{"X-Upos-Auth": pre.Auth}, nil, &up); err != nil {
return nil, err
}
if up.UploadID == "" {
return nil, perr(platform.PlatformChanged, "upload id missing", nil)
}
chunk := pre.ChunkSize
if chunk <= 0 {
chunk = 7 * 1024 * 1024
}
count := int((total + int64(chunk) - 1) / int64(chunk))
f, err := os.Open(path)
if err != nil {
return nil, perr(platform.Validation, "material unavailable", err)
}
defer f.Close()
parts := make([]map[string]interface{}, 0, count)
buf := make([]byte, chunk)
for i := 0; i < count; i++ {
n, er := io.ReadFull(f, buf)
if er != nil && er != io.ErrUnexpectedEOF && er != io.EOF {
return nil, perr(platform.Network, "reading material", er)
}
pq := url.Values{"uploadId": {up.UploadID}, "partNumber": {strconv.Itoa(i + 1)}, "chunk": {strconv.Itoa(i)}, "chunks": {strconv.Itoa(count)}, "size": {strconv.Itoa(n)}, "start": {strconv.Itoa(i * chunk)}, "end": {strconv.Itoa(i*chunk + n)}, "total": {strconv.FormatInt(total, 10)}}
etag, err := a.put(ctx, upURL+"?"+pq.Encode(), cookies, map[string]string{"X-Upos-Auth": pre.Auth}, buf[:n])
if err != nil {
return nil, fmt.Errorf("%w: part %d", err, i+1)
}
if etag == "" {
return nil, perr(platform.PlatformChanged, "upload response missing ETag", nil)
}
parts = append(parts, map[string]interface{}{"partNumber": i + 1, "eTag": etag})
}
mq := url.Values{"name": {name}, "uploadId": {up.UploadID}, "biz_id": {strconv.Itoa(pre.BizID)}, "output": {"json"}, "profile": {"ugcupos/bup"}}
var merged struct {
OK int `json:"OK"`
}
if err := a.postJSON(ctx, upURL+"?"+mq.Encode(), cookies, map[string]string{"X-Upos-Auth": pre.Auth}, map[string]interface{}{"parts": parts}, &merged); err != nil {
return nil, err
}
if merged.OK != 1 {
return nil, perr(platform.PlatformChanged, "merge failed", nil)
}
base := strings.TrimSuffix(filepath.Base(pre.UposURI), filepath.Ext(filepath.Base(pre.UposURI)))
return map[string]interface{}{"title": strings.TrimSuffix(name, filepath.Ext(name)), "filename": base, "desc": ""}, nil
}
func (a *Adapter) submit(ctx context.Context, cookies string, in platform.PublishInput, part map[string]interface{}) (string, error) {
tag := strings.Join(in.Tags, ",")
if tag == "" {
tag = "日常"
}
tid := in.CategoryID
if tid <= 0 {
tid = 174
}
body := map[string]interface{}{"title": truncate(in.Title, 80), "desc": in.Content, "desc_v2": []map[string]interface{}{{"raw_text": in.Content, "type": 1, "biz_id": ""}}, "copyright": 1, "source": "", "tid": tid, "tag": tag, "dynamic": "", "videos": []map[string]interface{}{part}}
q := url.Values{"csrf": {cookieValue(cookies, "bili_jct")}}
var ret struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
Bvid string `json:"bvid"`
} `json:"data"`
}
if err := a.postJSON(ctx, a.memberBase()+"/x/vu/web/add?"+q.Encode(), cookies, nil, body, &ret); err != nil {
return "", err
}
if ret.Code != 0 {
return "", perr(platform.Validation, ret.Message, nil)
}
return ret.Data.Bvid, nil
}
func (a *Adapter) getJSON(ctx context.Context, rawURL, cookies string, out interface{}) error {
raw, err := a.request(ctx, http.MethodGet, rawURL, cookies, nil, nil)
if err != nil {
return err
}
if err := json.Unmarshal(raw, out); err != nil {
return perr(platform.PlatformChanged, "invalid JSON response", err)
}
return nil
}
func (a *Adapter) postJSON(ctx context.Context, rawURL, cookies string, headers map[string]string, body, out interface{}) error {
var data io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return perr(platform.Validation, "invalid request", err)
}
data = bytes.NewReader(raw)
}
raw, err := a.request(ctx, http.MethodPost, rawURL, cookies, headers, data)
if err != nil {
return err
}
if out != nil {
if err := json.Unmarshal(raw, out); err != nil {
return perr(platform.PlatformChanged, "invalid JSON response", err)
}
}
return nil
}
func (a *Adapter) put(ctx context.Context, rawURL, cookies string, headers map[string]string, body []byte) (string, error) {
for attempt := 0; attempt <= a.retries(); attempt++ {
if err := ctx.Err(); err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, rawURL, bytes.NewReader(body))
if err != nil {
return "", perr(platform.Validation, "invalid URL", err)
}
if cookies != "" {
req.Header.Set("Cookie", cookies)
}
req.Header.Set("User-Agent", "Mozilla/5.0")
req.Header.Set("Referer", "https://member.bilibili.com")
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := a.client().Do(req)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return "", err
}
if attempt < a.retries() {
if !sleep(ctx, retryDelay(attempt)) {
return "", ctx.Err()
}
continue
}
return "", perr(platform.Network, "network request failed", err)
}
_, readErr := io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if readErr != nil {
return "", perr(platform.Network, "reading response", readErr)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if resp.StatusCode >= 500 && attempt < a.retries() {
if !sleep(ctx, retryDelay(attempt)) {
return "", ctx.Err()
}
continue
}
cat := platform.Unknown
if resp.StatusCode == 401 || resp.StatusCode == 403 {
cat = platform.AuthRequired
}
if resp.StatusCode == 429 {
cat = platform.RateLimited
}
if resp.StatusCode >= 500 {
cat = platform.Network
}
return "", perr(cat, "HTTP "+strconv.Itoa(resp.StatusCode), nil)
}
return strings.Trim(resp.Header.Get("ETag"), "\""), nil
}
return "", perr(platform.Network, "upload failed", nil)
}
func (a *Adapter) request(ctx context.Context, method, rawURL, cookies string, headers map[string]string, body io.Reader) ([]byte, error) {
var payload []byte
var err error
if body != nil {
payload, err = io.ReadAll(body)
if err != nil {
return nil, perr(platform.Network, "reading request body", err)
}
}
var last error
for attempt := 0; attempt <= a.retries(); attempt++ {
if err := ctx.Err(); err != nil {
return nil, err
}
var reqBody io.Reader
if body != nil {
reqBody = bytes.NewReader(payload)
}
req, err := http.NewRequestWithContext(ctx, method, rawURL, reqBody)
if err != nil {
return nil, perr(platform.Validation, "invalid URL", err)
}
if cookies != "" {
req.Header.Set("Cookie", cookies)
}
req.Header.Set("User-Agent", "Mozilla/5.0")
req.Header.Set("Referer", "https://member.bilibili.com")
if body != nil && method == http.MethodPost {
req.Header.Set("Content-Type", "application/json")
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := a.client().Do(req)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return nil, err
}
last = perr(platform.Network, "network request failed", err)
if attempt < a.retries() {
if !sleep(ctx, retryDelay(attempt)) {
return nil, ctx.Err()
}
continue
}
return nil, last
}
raw, readErr := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
resp.Body.Close()
if readErr != nil {
return nil, perr(platform.Network, "reading response", readErr)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
cat := platform.Unknown
if resp.StatusCode == 401 || resp.StatusCode == 403 {
cat = platform.AuthRequired
}
if resp.StatusCode == 429 {
cat = platform.RateLimited
}
if resp.StatusCode >= 500 {
cat = platform.Network
}
last = perr(cat, "HTTP "+strconv.Itoa(resp.StatusCode), nil)
if resp.StatusCode >= 500 && attempt < a.retries() {
if !sleep(ctx, retryDelay(attempt)) {
return nil, ctx.Err()
}
continue
}
return nil, last
}
return raw, nil
}
return nil, last
}
func (a *Adapter) client() *http.Client {
if a.HTTP != nil {
return a.HTTP
}
return http.DefaultClient
}
func (a *Adapter) retries() int {
if a.MaxRetries < 0 {
return 0
}
if a.MaxRetries == 0 {
return 2
}
return a.MaxRetries
}
func retryDelay(n int) time.Duration { return time.Duration(25*(1<<n)) * time.Millisecond }
func sleep(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}
func (a *Adapter) memberBase() string {
if a.MemberBase != "" {
return strings.TrimRight(a.MemberBase, "/")
}
return "https://member.bilibili.com"
}
func (a *Adapter) passportBase() string {
if a.PassportBase != "" {
return strings.TrimRight(a.PassportBase, "/")
}
return "https://passport.bilibili.com"
}
func (a *Adapter) store() *credentials.Store {
if a.Store != nil {
return a.Store
}
return a.Credentials
}
func (a *Adapter) upURL(endpoint, uri string) string {
scheme := a.UpOSScheme
if scheme == "" {
scheme = "https"
}
if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") {
return strings.TrimRight(endpoint, "/") + "/" + strings.TrimPrefix(uri, "upos://")
}
return scheme + "://" + strings.TrimRight(strings.TrimPrefix(endpoint, "//"), "/") + "/" + strings.TrimPrefix(uri, "upos://")
}
// cookiesFromURL extracts only the three cookies accepted by Bilibili's
// cross-domain login redirect; all query values are URL-decoded by net/url.
func cookiesFromURL(raw string) string {
u, err := url.Parse(raw)
if err != nil {
return ""
}
q := u.Query()
sess := q.Get("SESSDATA")
csrf := q.Get("bili_jct")
uid := q.Get("DedeUserID")
if sess == "" || csrf == "" || uid == "" {
return ""
}
return "SESSDATA=" + sess + "; bili_jct=" + csrf + "; DedeUserID=" + uid
}
func cookieValue(cookies, key string) string {
for _, p := range strings.Split(cookies, ";") {
p = strings.TrimSpace(p)
i := strings.IndexByte(p, '=')
if i > 0 && p[:i] == key {
return p[i+1:]
}
}
return ""
}
func truncate(s string, n int) string {
r := []rune(s)
if len(r) > n {
return string(r[:n])
}
return s
}
func perr(cat platform.ErrorCategory, msg string, err error) error {
return &platform.Error{Category: cat, Message: msg, Err: err}
}