| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277 |
- // ============================================================================
- // 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)
- }
|