client: Windows 客户端(WinUI3 壳 + agent-core;调试用 Electron 壳存档);localserver token 改为常数时间比较
This commit is contained in:
@@ -0,0 +1,458 @@
|
||||
// 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])
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package bilibili
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"everypublish/agentcore/internal/exec"
|
||||
"everypublish/agentcore/internal/vault"
|
||||
"everypublish/shared/proto"
|
||||
)
|
||||
|
||||
// stubBili 桩:preupload + upos(uploads/分片/合并)+ 投稿 add,记录关键请求
|
||||
func stubBili(t *testing.T) (*httptest.Server, *struct {
|
||||
PartQuery string
|
||||
PartsLen int
|
||||
AddBody map[string]interface{}
|
||||
AddURL string
|
||||
XUposAuth string
|
||||
}) {
|
||||
rec := &struct {
|
||||
PartQuery string
|
||||
PartsLen int
|
||||
AddBody map[string]interface{}
|
||||
AddURL string
|
||||
XUposAuth string
|
||||
}{}
|
||||
var srv *httptest.Server
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.URL.Path == "/preupload":
|
||||
// endpoint 指向桩服务器自身
|
||||
endpoint := "//" + strings.TrimPrefix(srv.URL, "http://")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"OK": 1, "endpoint": endpoint, "auth": "auth-x", "biz_id": 1000,
|
||||
"upos_uri": "upos://video/av123", "chunk_size": 5,
|
||||
})
|
||||
case strings.Contains(r.URL.RawQuery, "uploads"):
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"upload_id": "upload-1"})
|
||||
case r.Method == http.MethodPut:
|
||||
rec.PartQuery = r.URL.RawQuery
|
||||
rec.XUposAuth = r.Header.Get("X-Upos-Auth")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case r.Method == http.MethodPost && strings.Contains(r.URL.RawQuery, "output=json"):
|
||||
var body map[string]interface{}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if parts, ok := body["parts"].([]interface{}); ok {
|
||||
rec.PartsLen = len(parts)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]int{"OK": 1})
|
||||
case r.URL.Path == "/x/vu/web/add":
|
||||
rec.AddURL = r.URL.RawQuery
|
||||
_ = json.NewDecoder(r.Body).Decode(&rec.AddBody)
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": 0, "data": map[string]string{"bvid": "BV1TEST123"},
|
||||
})
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
return srv, rec
|
||||
}
|
||||
|
||||
func TestExecuteUploadFlow(t *testing.T) {
|
||||
// 素材服务器
|
||||
matSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("0123456789abcdef"))
|
||||
}))
|
||||
defer matSrv.Close()
|
||||
|
||||
srv, rec := stubBili(t)
|
||||
defer srv.Close()
|
||||
|
||||
v, _ := vault.Open(t.TempDir())
|
||||
_ = v.Save("77", []byte(`{"cookies":"SESSDATA=sess; bili_jct=csrf-x; DedeUserID=1"}`))
|
||||
|
||||
e := New(v)
|
||||
e.MemberBase = srv.URL
|
||||
e.PassportBase = srv.URL
|
||||
e.UpOSScheme = "http"
|
||||
|
||||
res := e.Execute(context.Background(), exec.Task{
|
||||
TaskID: "9", AccountID: "77", Title: "测试标题", Content: "简介",
|
||||
Tags: []string{"测试", "日常"}, MaterialURLs: []string{matSrv.URL + "/v.mp4"},
|
||||
})
|
||||
if res.Status != "success" {
|
||||
t.Fatalf("expect success, got %+v", res)
|
||||
}
|
||||
if res.PublishedURL != "https://www.bilibili.com/video/BV1TEST123" {
|
||||
t.Fatalf("published url wrong: %s", res.PublishedURL)
|
||||
}
|
||||
// rec.PartQuery 记录最后一片(partNumber=4 size=1 start=15 end=16)
|
||||
if !strings.Contains(rec.PartQuery, "partNumber=4") || !strings.Contains(rec.PartQuery, "chunks=4") ||
|
||||
!strings.Contains(rec.PartQuery, "total=16") || !strings.Contains(rec.PartQuery, "size=1") {
|
||||
t.Fatalf("chunk query wrong: %s", rec.PartQuery)
|
||||
}
|
||||
if rec.PartsLen != 4 {
|
||||
t.Fatalf("expect 4 merged parts, got %d", rec.PartsLen)
|
||||
}
|
||||
if rec.AddURL != "csrf=csrf-x" {
|
||||
t.Fatalf("add csrf wrong: %s", rec.AddURL)
|
||||
}
|
||||
if rec.AddBody["title"] != "测试标题" || rec.AddBody["copyright"] != float64(1) {
|
||||
t.Fatalf("add body wrong: %+v", rec.AddBody)
|
||||
}
|
||||
if videos, ok := rec.AddBody["videos"].([]interface{}); !ok || len(videos) != 1 {
|
||||
t.Fatal("videos missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolveChallengeQRLogin(t *testing.T) {
|
||||
pollAttempts := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "generate"):
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": 0, "data": map[string]string{"url": "https://qr.example/x", "qrcode_key": "key-1"},
|
||||
})
|
||||
case strings.Contains(r.URL.Path, "poll"):
|
||||
pollAttempts++
|
||||
if pollAttempts == 1 {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 86101, "data": map[string]string{}})
|
||||
} else {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]string{
|
||||
"url": "https://passport.biligame.com/x/passport-login/web/crossDomain?SESSDATA=sess123&bili_jct=jct456&DedeUserID=789",
|
||||
},
|
||||
})
|
||||
}
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
v, _ := vault.Open(t.TempDir())
|
||||
e := New(v)
|
||||
e.MemberBase = srv.URL
|
||||
e.PassportBase = srv.URL
|
||||
e.PollTimeout = 10 * time.Second
|
||||
|
||||
var gotQR string
|
||||
solved, value := e.SolveChallenge(context.Background(), proto.Challenge{ChallengeID: "1", AccountID: "77"}, func(token, qrURL string) {
|
||||
gotQR = qrURL
|
||||
})
|
||||
if !solved || value != "confirmed" {
|
||||
t.Fatalf("expect solved, got %v %s", solved, value)
|
||||
}
|
||||
if gotQR == "" {
|
||||
t.Fatal("onQR should be called")
|
||||
}
|
||||
raw, err := v.Load("77")
|
||||
if err != nil {
|
||||
t.Fatalf("vault should have cookies: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), "SESSDATA=sess123") {
|
||||
t.Fatalf("cookies not saved: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollTimeoutUnscanned(t *testing.T) {
|
||||
// 一直未扫码:应超时返回未解决
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if strings.Contains(r.URL.Path, "generate") {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 0, "data": map[string]string{"url": "u", "qrcode_key": "k"}})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 86101, "data": map[string]string{}})
|
||||
}))
|
||||
defer srv.Close()
|
||||
v, _ := vault.Open(t.TempDir())
|
||||
e := New(v)
|
||||
e.MemberBase = srv.URL
|
||||
e.PassportBase = srv.URL
|
||||
e.PollTimeout = 3 * time.Second
|
||||
if solved, _ := e.SolveChallenge(context.Background(), proto.Challenge{AccountID: "1"}, nil); solved {
|
||||
t.Fatal("unscanned should timeout unsolved")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user