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<