Files
EveryPublish/client/core/internal/exec/bilibili/bilibili.go
T

459 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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])
}