// ============================================================================ // bilifeed.go — B站关注动态抓取 // // 需要登录态(cookie 含 SESSDATA),抓取「关注的人的最新动态」。 // 接口:GET https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/all?type=all&page=1 // 该接口本身不需 WBI 签名,但需带登录 cookie 与 buvid3,否则返回 code=-101 账号未登录。 // ============================================================================ package main import ( "encoding/json" "fmt" "io" "net/http" "strings" "time" ) const ( biliDynamicURL = "https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/all" biliHomeURL = "https://www.bilibili.com/" ) // BiliDynamicItem 一条 B站动态,供「新闻」页展示 type BiliDynamicItem struct { Source string `json:"source"` // 来源:B站动态 Title string `json:"title"` // 标题(动态文案或视频标题) URL string `json:"url"` // 详情页地址 Matched bool `json:"matched"` // 是否命中关注关键词 } // biliFeedPayload 动态 feed 接口响应结构(只取需要的字段) type biliFeedPayload struct { Code int `json:"code"` Message string `json:"message"` Data struct { HasMore bool `json:"has_more"` Items []struct { IDStr string `json:"id_str"` Modules struct { ModuleAuthor struct { Name string `json:"name"` // 发布者昵称 } `json:"module_author"` ModuleDynamic struct { Desc struct { Text string `json:"text"` // 动态文案 } `json:"desc"` } `json:"module_dynamic"` ModuleArchival struct { Archival struct { Title string `json:"title"` // 视频标题 Bvid string `json:"bvid"` // 视频 bvid,用于拼详情地址 Desc string `json:"desc"` // 视频简介 History struct { Text string `json:"text"` // 时长/进度 } `json:"history"` } `json:"archival"` } `json:"module_archival"` } `json:"modules"` } `json:"items"` } `json:"data"` } // fetchBiliDynamic 抓取 B站关注动态(需登录 cookie)。cookie 为空返回明确错误。 func fetchBiliDynamic(cookie string) ([]BiliDynamicItem, error) { cookie = strings.TrimSpace(cookie) if cookie == "" { return nil, fmt.Errorf("未配置 B站登录 cookie(设置 → 新闻 → B站 cookie)") } // 先访问主页拿 buvid3/buvid4(动态接口可能要求),再把 cookie 一起带上 _, buvids := fetchBuvid3() client := &http.Client{Timeout: 12 * time.Second} req, err := http.NewRequest(http.MethodGet, biliDynamicURL+"?type=all&page=1", nil) if err != nil { return nil, err } req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36") req.Header.Set("Referer", biliHomeURL) req.Header.Set("Accept", "application/json") // cookie 拼接:登录 cookie + buvid3/buvid4(若有) allCookie := cookie if buvids != "" { allCookie = strings.TrimSpace(allCookie + "; " + buvids) } req.Header.Set("Cookie", allCookie) resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } var payload biliFeedPayload if err := json.Unmarshal(body, &payload); err != nil { return nil, fmt.Errorf("解析 B站动态失败: %w", err) } if payload.Code == -101 { return nil, fmt.Errorf("B站账号未登录:cookie 无效或已过期,请更新设置里的 B站 cookie") } if payload.Code != 0 { return nil, fmt.Errorf("B站动态接口错误 code=%d message=%s", payload.Code, payload.Message) } items := make([]BiliDynamicItem, 0, len(payload.Data.Items)) for _, it := range payload.Data.Items { author := strings.TrimSpace(it.Modules.ModuleAuthor.Name) // 优先取视频标题(module_archival),否则取动态文案(module_dynamic.desc) archTitle := strings.TrimSpace(it.Modules.ModuleArchival.Archival.Title) bvid := strings.TrimSpace(it.Modules.ModuleArchival.Archival.Bvid) dynText := strings.TrimSpace(it.Modules.ModuleDynamic.Desc.Text) var title, link string if archTitle != "" { title = archTitle if bvid != "" { link = "https://www.bilibili.com/video/" + bvid } } else if dynText != "" { title = dynText link = "https://t.bilibili.com/" + it.IDStr } else { continue // 无标题无视频:跳过 } title = truncateText(title, 120) // 长文案截断,避免卡片撑爆 if link == "" { link = "https://t.bilibili.com/" + it.IDStr } if author != "" { title = author + ":" + title } items = append(items, BiliDynamicItem{Source: "B站动态", Title: title, URL: link}) if len(items) >= 30 { break } } if len(items) == 0 { return nil, fmt.Errorf("B站动态为空(可能没有关注动态)") } return items, nil } // fetchBuvid3 访问 B站主页获取 buvid3 / buvid4 cookie(动态接口可能要求校验) func fetchBuvid3() (buvid string, extra string) { client := &http.Client{Timeout: 8 * time.Second} req, err := http.NewRequest(http.MethodGet, biliHomeURL, nil) if err != nil { return "", "" } req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36") resp, err := client.Do(req) if err != nil { return "", "" } defer resp.Body.Close() io.Copy(io.Discard, resp.Body) // 读走 body 以便复用连接 var parts []string for _, c := range resp.Cookies() { if c.Name == "buvid3" || c.Name == "buvid4" { parts = append(parts, c.Name+"="+c.Value) } } if len(parts) == 0 { return "", "" } return "", strings.Join(parts, "; ") } // truncateText 截断长文本到 maxRunes 个字符(按 rune 计,避免切断多字节) func truncateText(s string, maxRunes int) string { r := []rune(s) if len(r) <= maxRunes { return s } return string(r[:maxRunes]) + "…" } // ------------------------------------------------------------------------ // 备用:B站热门 / 排行榜(无需登录),可作为 cookie 失效时的兜底。 // ------------------------------------------------------------------------ const ( biliPopularURL = "https://api.bilibili.com/x/web-interface/popular" biliRankURL = "https://api.bilibili.com/x/web-interface/ranking/v2" ) // biliVideoListPayload 热门 / 排行接口响应结构 type biliVideoListPayload struct { Code int `json:"code"` Message string `json:"message"` Data struct { List []struct { Title string `json:"title"` // 视频标题 Bvid string `json:"bvid"` // 视频 bvid } `json:"list"` } `json:"data"` } // fetchBiliPopular 抓取 B站热门视频(取前 20 条,无需登录) func fetchBiliPopular() ([]BiliDynamicItem, error) { return fetchBiliVideoList(biliPopularURL + "?ps=20&pn=1") } // fetchBiliRank 抓取 B站排行榜(取前 20 条,无需登录) func fetchBiliRank() ([]BiliDynamicItem, error) { return fetchBiliVideoList(biliRankURL + "?rid=0&type=all") } func fetchBiliVideoList(apiURL string) ([]BiliDynamicItem, error) { client := &http.Client{Timeout: 10 * time.Second} req, err := http.NewRequest(http.MethodGet, apiURL, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36") req.Header.Set("Referer", biliHomeURL) req.Header.Set("Accept", "application/json") resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } var payload biliVideoListPayload if err := json.Unmarshal(body, &payload); err != nil { return nil, fmt.Errorf("解析 B站列表失败: %w", err) } if payload.Code != 0 { return nil, fmt.Errorf("B站列表接口错误 code=%d message=%s", payload.Code, payload.Message) } items := make([]BiliDynamicItem, 0, len(payload.Data.List)) for _, v := range payload.Data.List { title := strings.TrimSpace(v.Title) if title == "" || v.Bvid == "" { continue } items = append(items, BiliDynamicItem{ Source: "B站", Title: truncateText(title, 120), URL: "https://www.bilibili.com/video/" + v.Bvid, }) if len(items) >= 20 { break } } if len(items) == 0 { return nil, fmt.Errorf("列表为空") } return items, nil } // ------------------------------------------------------------------------ // 组合抓取:B站动态(需 cookie)+ 热门兜底。 // ------------------------------------------------------------------------ // fetchBiliFeed 优先抓关注动态,cookie 为空或失效时回退热门视频。 // 返回 (动态列表, 是否使用了兜底, 错误)。 func fetchBiliFeed(cookie string) ([]BiliDynamicItem, bool, error) { dyn, err := fetchBiliDynamic(cookie) if err == nil { return dyn, false, nil // 正常拿到关注动态 } // 兜底:热门视频 pop, popErr := fetchBiliPopular() if popErr == nil { return pop, true, nil // 用热门兜底(动态失败但热门成功) } return nil, true, fmt.Errorf("B站动态失败:%v;热门兜底也失败:%v", err, popErr) }