551 lines
17 KiB
Go
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}
|
|
}
|