// bilibili B站执行器(无浏览器投稿 + API 扫码登录)。 // 协议依据:ForgQi/biliup(MIT)bili_webup.py 的 upos/bda2 上传链路。 package bilibili import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "strconv" "strings" "time" "everypublish/agentcore/internal/exec" "everypublish/agentcore/internal/vault" "everypublish/shared/proto" ) // Executor B站投稿执行器 type Executor struct { Vault *vault.Vault HTTP *http.Client MemberBase string // 投稿 API 基址(测试可替换) PassportBase string // 登录 API 基址(测试可替换) UpOSScheme string // upos 上传协议(默认 https,测试 http) PollTimeout time.Duration // 扫码轮询总时长(默认 3 分钟) } // New 构造执行器 func New(v *vault.Vault) *Executor { return &Executor{ Vault: v, HTTP: &http.Client{Timeout: 30 * time.Second}, MemberBase: "https://member.bilibili.com", PassportBase: "https://passport.bilibili.com", UpOSScheme: "https", PollTimeout: 3 * time.Minute, } } // Platform 平台标识 func (e *Executor) Platform() string { return "bilibili" } // credentials 凭据(保险库 JSON) type credentials struct { Cookies string `json:"cookies"` } // Execute 发布:下载素材 → upos 上传 → 投稿 → 返回作品链接 func (e *Executor) Execute(ctx context.Context, task exec.Task) exec.Result { fail := func(err string) exec.Result { return exec.Result{TaskID: task.TaskID, Status: "failed", Error: err} } creds, err := e.creds(task.AccountID) if err != nil { return fail("B站账号未登录(本地无凭据),请先在客户端完成扫码绑定") } if len(task.MaterialURLs) == 0 { return fail("任务缺少素材") } videoPath, cleanup, err := downloadFile(ctx, e.HTTP, task.MaterialURLs[0]) if err != nil { return fail("素材下载失败: " + err.Error()) } defer cleanup() part, err := e.uploadVideo(ctx, creds.Cookies, videoPath) if err != nil { return fail("视频上传失败: " + err.Error()) } bvid, err := e.submit(ctx, creds.Cookies, task, part) if err != nil { return fail("投稿提交失败: " + err.Error()) } return exec.Result{ TaskID: task.TaskID, Status: "success", PublishedURL: "https://www.bilibili.com/video/" + bvid, } } func (e *Executor) creds(accountID string) (*credentials, error) { raw, err := e.Vault.Load(accountID) if err != nil { return nil, err } var c credentials if err = json.Unmarshal(raw, &c); err != nil { return nil, err } if c.Cookies == "" { return nil, fmt.Errorf("empty cookies") } return &c, nil } // uploadVideo 上传视频(upos/bda2 分片),返回投稿所需的 part 信息 func (e *Executor) uploadVideo(ctx context.Context, cookies, filePath string) (map[string]interface{}, error) { stat, err := os.Stat(filePath) if err != nil { return nil, err } name := filepath.Base(filePath) total := stat.Size() // 1) preupload 获取上传地址 q := url.Values{} q.Set("r", "upos") q.Set("profile", "ugcupos/bup") q.Set("ssl", "0") q.Set("version", "2.8.12") q.Set("build", "2081200") q.Set("name", name) q.Set("size", strconv.FormatInt(total, 10)) preURL := e.MemberBase + "/preupload?upcdn=bda2&probe_version=20221109&" + q.Encode() 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 = e.getJSON(ctx, preURL, cookies, &pre); err != nil { return nil, err } if pre.OK != 1 || pre.UposURI == "" { return nil, fmt.Errorf("preupload 返回异常") } upURL := e.UpOSScheme + ":" + pre.Endpoint + "/" + strings.TrimPrefix(pre.UposURI, "upos://") authH := map[string]string{"X-Upos-Auth": pre.Auth} // 2) 申请 upload_id var up struct { UploadID string `json:"upload_id"` } if err = e.postJSON(ctx, upURL+"?uploads&output=json", cookies, authH, nil, &up); err != nil { return nil, fmt.Errorf("申请 upload_id 失败: %w", err) } // 3) 分片上传 chunkSize := pre.ChunkSize if chunkSize <= 0 { chunkSize = 7 * 1024 * 1024 } chunks := int((total + int64(chunkSize) - 1) / int64(chunkSize)) f, err := os.Open(filePath) if err != nil { return nil, err } defer f.Close() parts := make([]map[string]interface{}, 0, chunks) buf := make([]byte, chunkSize) for i := 0; i < chunks; i++ { n, rerr := io.ReadFull(f, buf) if rerr != nil && rerr != io.ErrUnexpectedEOF && rerr != io.EOF { return nil, rerr } start := i * chunkSize end := start + n pq := url.Values{} pq.Set("uploadId", up.UploadID) pq.Set("partNumber", strconv.Itoa(i+1)) pq.Set("chunk", strconv.Itoa(i)) pq.Set("chunks", strconv.Itoa(chunks)) pq.Set("size", strconv.Itoa(n)) pq.Set("start", strconv.Itoa(start)) pq.Set("end", strconv.Itoa(end)) pq.Set("total", strconv.FormatInt(total, 10)) if err = e.putBody(ctx, upURL+"?"+pq.Encode(), cookies, authH, buf[:n]); err != nil { return nil, fmt.Errorf("分片 %d 上传失败: %w", i+1, err) } parts = append(parts, map[string]interface{}{"partNumber": i + 1, "eTag": "etag"}) } // 4) 合并分片 mq := url.Values{} mq.Set("name", name) mq.Set("uploadId", up.UploadID) mq.Set("biz_id", strconv.Itoa(pre.BizID)) mq.Set("output", "json") mq.Set("profile", "ugcupos/bup") var merged struct { OK int `json:"OK"` } if err = e.postJSON(ctx, upURL+"?"+mq.Encode(), cookies, authH, map[string]interface{}{"parts": parts}, &merged); err != nil { return nil, err } if merged.OK != 1 { return nil, fmt.Errorf("分片合并返回异常") } return map[string]interface{}{ "title": strings.TrimSuffix(name, filepath.Ext(name)), "filename": strings.TrimSuffix(filepath.Base(pre.UposURI), filepath.Ext(filepath.Base(pre.UposURI))), "desc": "", }, nil } // submit 投稿(x/vu/web/add) func (e *Executor) submit(ctx context.Context, cookies string, task exec.Task, part map[string]interface{}) (string, error) { csrf := cookieValue(cookies, "bili_jct") // 风控预热(失败不影响) _, _ = e.get(ctx, e.MemberBase+"/x/geetest/pre/add", cookies) tag := strings.Join(task.Tags, ",") if tag == "" { tag = "日常" } body := map[string]interface{}{ "title": truncateRunes(task.Title, 80), "desc": task.Content, "desc_v2": []map[string]interface{}{{"raw_text": task.Content, "type": 1, "biz_id": ""}}, "copyright": 1, "source": "", "tid": 174, "tag": tag, "dynamic": "", "videos": []map[string]interface{}{part}, } var ret struct { Code int `json:"code"` Message string `json:"message"` Data struct { Bvid string `json:"bvid"` } `json:"data"` } if err := e.postJSON(ctx, e.MemberBase+"/x/vu/web/add?csrf="+csrf, cookies, nil, body, &ret); err != nil { return "", err } if ret.Code != 0 { return "", fmt.Errorf("code=%d %s", ret.Code, ret.Message) } return ret.Data.Bvid, nil } // SolveChallenge B站扫码登录:生成二维码 → onQR 展示 → 轮询 → 保存 cookie func (e *Executor) SolveChallenge(ctx context.Context, ch proto.Challenge, onQR func(qrToken, qrURL string)) (bool, string) { key, qrURL, err := e.generateQR(ctx) if err != nil { return false, "" } if onQR != nil { onQR(key, qrURL) } deadline := time.Now().Add(e.PollTimeout) for time.Now().Before(deadline) { select { case <-ctx.Done(): return false, "" default: } code, cookieURL, perr := e.pollQR(ctx, key) if perr != nil { return false, "" } switch code { case 0: cookies := cookiesFromCrossDomainURL(cookieURL) if cookies == "" { return false, "" } _ = e.Vault.Save(ch.AccountID, []byte(`{"cookies":"`+jsonEscape(cookies)+`"}`)) return true, "confirmed" case 86090: case 86101: default: return false, "" } time.Sleep(2 * time.Second) } return false, "" } // generateQR 生成登录二维码 func (e *Executor) generateQR(ctx context.Context) (key, qrURL string, err error) { var ret struct { Code int `json:"code"` Data struct { URL string `json:"url"` QrcodeKey string `json:"qrcode_key"` } `json:"data"` } if err = e.getJSON(ctx, e.PassportBase+"/x/passport-login/web/qrcode/generate", "", &ret); err != nil { return "", "", err } if ret.Code != 0 || ret.Data.QrcodeKey == "" { return "", "", fmt.Errorf("生成二维码失败 code=%d", ret.Code) } return ret.Data.QrcodeKey, ret.Data.URL, nil } // pollQR 轮询扫码状态:86101 未扫 / 86090 已扫待确认 / 0 成功(返回跨域 cookie URL) func (e *Executor) pollQR(ctx context.Context, key string) (int, string, error) { var ret struct { Code int `json:"code"` Data struct { URL string `json:"url"` } `json:"data"` } if err := e.getJSON(ctx, e.PassportBase+"/x/passport-login/web/qrcode/poll?qrcode_key="+key, "", &ret); err != nil { return 0, "", err } return ret.Code, ret.Data.URL, nil } // ---- HTTP 辅助 ---- func (e *Executor) get(ctx context.Context, rawURL, cookies string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return nil, err } if cookies != "" { req.Header.Set("Cookie", cookies) } req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36") req.Header.Set("Referer", "https://member.bilibili.com") resp, err := e.HTTP.Do(req) if err != nil { return nil, err } defer resp.Body.Close() return io.ReadAll(resp.Body) } func (e *Executor) getJSON(ctx context.Context, rawURL, cookies string, out interface{}) error { raw, err := e.get(ctx, rawURL, cookies) if err != nil { return err } return json.Unmarshal(raw, out) } func (e *Executor) postJSON(ctx context.Context, rawURL, cookies string, extraH map[string]string, body interface{}, out interface{}) error { var buf bytes.Buffer if body != nil { raw, _ := json.Marshal(body) buf.Write(raw) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, rawURL, &buf) if err != nil { return err } if cookies != "" { req.Header.Set("Cookie", cookies) } for k, v := range extraH { req.Header.Set(k, v) } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36") req.Header.Set("Referer", "https://member.bilibili.com") resp, err := e.HTTP.Do(req) if err != nil { return err } defer resp.Body.Close() raw, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 400 { return fmt.Errorf("http %d", resp.StatusCode) } if out == nil { return nil } return json.Unmarshal(raw, out) } func (e *Executor) putBody(ctx context.Context, rawURL, cookies string, extraH map[string]string, data []byte) error { req, err := http.NewRequestWithContext(ctx, http.MethodPut, rawURL, bytes.NewReader(data)) if err != nil { return err } if cookies != "" { req.Header.Set("Cookie", cookies) } for k, v := range extraH { req.Header.Set(k, v) } resp, err := e.HTTP.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 400 { return fmt.Errorf("http %d", resp.StatusCode) } return nil } // downloadFile 下载素材到临时文件 func downloadFile(ctx context.Context, client *http.Client, rawURL string) (string, func(), error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return "", nil, err } resp, err := client.Do(req) if err != nil { return "", nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", nil, fmt.Errorf("下载 http %d", resp.StatusCode) } tmp, err := os.CreateTemp("", "ep-material-*") if err != nil { return "", nil, err } _, err = io.Copy(tmp, resp.Body) tmp.Close() if err != nil { os.Remove(tmp.Name()) return "", nil, err } return tmp.Name(), func() { _ = os.Remove(tmp.Name()) }, nil } // cookieValue 从 cookie 串取值 func cookieValue(cookies, key string) string { for _, part := range strings.Split(cookies, ";") { part = strings.TrimSpace(part) if i := strings.Index(part, "="); i > 0 && part[:i] == key { return part[i+1:] } } return "" } // cookiesFromCrossDomainURL 从跨域跳转 URL 提取登录 cookie func cookiesFromCrossDomainURL(rawURL string) string { u, err := url.Parse(rawURL) if err != nil { return "" } q := u.Query() sess := q.Get("SESSDATA") jct := q.Get("bili_jct") dede := q.Get("DedeUserID") if sess == "" { return "" } return "SESSDATA=" + sess + "; bili_jct=" + jct + "; DedeUserID=" + dede } func truncateRunes(s string, max int) string { r := []rune(s) if len(r) <= max { return s } return string(r[:max]) } func jsonEscape(s string) string { raw, _ := json.Marshal(s) return string(raw[1 : len(raw)-1]) }