bilifeed.go 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. // ============================================================================
  2. // bilifeed.go — B站关注动态抓取
  3. //
  4. // 需要登录态(cookie 含 SESSDATA),抓取「关注的人的最新动态」。
  5. // 接口:GET https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/all?type=all&page=1
  6. // 该接口本身不需 WBI 签名,但需带登录 cookie 与 buvid3,否则返回 code=-101 账号未登录。
  7. // ============================================================================
  8. package main
  9. import (
  10. "encoding/json"
  11. "fmt"
  12. "io"
  13. "net/http"
  14. "strings"
  15. "time"
  16. )
  17. const (
  18. biliDynamicURL = "https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/all"
  19. biliHomeURL = "https://www.bilibili.com/"
  20. )
  21. // BiliDynamicItem 一条 B站动态,供「新闻」页展示
  22. type BiliDynamicItem struct {
  23. Source string `json:"source"` // 来源:B站动态
  24. Title string `json:"title"` // 标题(动态文案或视频标题)
  25. URL string `json:"url"` // 详情页地址
  26. Matched bool `json:"matched"` // 是否命中关注关键词
  27. }
  28. // biliFeedPayload 动态 feed 接口响应结构(只取需要的字段)
  29. type biliFeedPayload struct {
  30. Code int `json:"code"`
  31. Message string `json:"message"`
  32. Data struct {
  33. HasMore bool `json:"has_more"`
  34. Items []struct {
  35. IDStr string `json:"id_str"`
  36. Modules struct {
  37. ModuleAuthor struct {
  38. Name string `json:"name"` // 发布者昵称
  39. } `json:"module_author"`
  40. ModuleDynamic struct {
  41. Desc struct {
  42. Text string `json:"text"` // 动态文案
  43. } `json:"desc"`
  44. } `json:"module_dynamic"`
  45. ModuleArchival struct {
  46. Archival struct {
  47. Title string `json:"title"` // 视频标题
  48. Bvid string `json:"bvid"` // 视频 bvid,用于拼详情地址
  49. Desc string `json:"desc"` // 视频简介
  50. History struct {
  51. Text string `json:"text"` // 时长/进度
  52. } `json:"history"`
  53. } `json:"archival"`
  54. } `json:"module_archival"`
  55. } `json:"modules"`
  56. } `json:"items"`
  57. } `json:"data"`
  58. }
  59. // fetchBiliDynamic 抓取 B站关注动态(需登录 cookie)。cookie 为空返回明确错误。
  60. func fetchBiliDynamic(cookie string) ([]BiliDynamicItem, error) {
  61. cookie = strings.TrimSpace(cookie)
  62. if cookie == "" {
  63. return nil, fmt.Errorf("未配置 B站登录 cookie(设置 → 新闻 → B站 cookie)")
  64. }
  65. // 先访问主页拿 buvid3/buvid4(动态接口可能要求),再把 cookie 一起带上
  66. _, buvids := fetchBuvid3()
  67. client := &http.Client{Timeout: 12 * time.Second}
  68. req, err := http.NewRequest(http.MethodGet, biliDynamicURL+"?type=all&page=1", nil)
  69. if err != nil {
  70. return nil, err
  71. }
  72. 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")
  73. req.Header.Set("Referer", biliHomeURL)
  74. req.Header.Set("Accept", "application/json")
  75. // cookie 拼接:登录 cookie + buvid3/buvid4(若有)
  76. allCookie := cookie
  77. if buvids != "" {
  78. allCookie = strings.TrimSpace(allCookie + "; " + buvids)
  79. }
  80. req.Header.Set("Cookie", allCookie)
  81. resp, err := client.Do(req)
  82. if err != nil {
  83. return nil, err
  84. }
  85. defer resp.Body.Close()
  86. body, err := io.ReadAll(resp.Body)
  87. if err != nil {
  88. return nil, err
  89. }
  90. var payload biliFeedPayload
  91. if err := json.Unmarshal(body, &payload); err != nil {
  92. return nil, fmt.Errorf("解析 B站动态失败: %w", err)
  93. }
  94. if payload.Code == -101 {
  95. return nil, fmt.Errorf("B站账号未登录:cookie 无效或已过期,请更新设置里的 B站 cookie")
  96. }
  97. if payload.Code != 0 {
  98. return nil, fmt.Errorf("B站动态接口错误 code=%d message=%s", payload.Code, payload.Message)
  99. }
  100. items := make([]BiliDynamicItem, 0, len(payload.Data.Items))
  101. for _, it := range payload.Data.Items {
  102. author := strings.TrimSpace(it.Modules.ModuleAuthor.Name)
  103. // 优先取视频标题(module_archival),否则取动态文案(module_dynamic.desc)
  104. archTitle := strings.TrimSpace(it.Modules.ModuleArchival.Archival.Title)
  105. bvid := strings.TrimSpace(it.Modules.ModuleArchival.Archival.Bvid)
  106. dynText := strings.TrimSpace(it.Modules.ModuleDynamic.Desc.Text)
  107. var title, link string
  108. if archTitle != "" {
  109. title = archTitle
  110. if bvid != "" {
  111. link = "https://www.bilibili.com/video/" + bvid
  112. }
  113. } else if dynText != "" {
  114. title = dynText
  115. link = "https://t.bilibili.com/" + it.IDStr
  116. } else {
  117. continue // 无标题无视频:跳过
  118. }
  119. title = truncateText(title, 120) // 长文案截断,避免卡片撑爆
  120. if link == "" {
  121. link = "https://t.bilibili.com/" + it.IDStr
  122. }
  123. if author != "" {
  124. title = author + ":" + title
  125. }
  126. items = append(items, BiliDynamicItem{Source: "B站动态", Title: title, URL: link})
  127. if len(items) >= 30 {
  128. break
  129. }
  130. }
  131. if len(items) == 0 {
  132. return nil, fmt.Errorf("B站动态为空(可能没有关注动态)")
  133. }
  134. return items, nil
  135. }
  136. // fetchBuvid3 访问 B站主页获取 buvid3 / buvid4 cookie(动态接口可能要求校验)
  137. func fetchBuvid3() (buvid string, extra string) {
  138. client := &http.Client{Timeout: 8 * time.Second}
  139. req, err := http.NewRequest(http.MethodGet, biliHomeURL, nil)
  140. if err != nil {
  141. return "", ""
  142. }
  143. 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")
  144. resp, err := client.Do(req)
  145. if err != nil {
  146. return "", ""
  147. }
  148. defer resp.Body.Close()
  149. io.Copy(io.Discard, resp.Body) // 读走 body 以便复用连接
  150. var parts []string
  151. for _, c := range resp.Cookies() {
  152. if c.Name == "buvid3" || c.Name == "buvid4" {
  153. parts = append(parts, c.Name+"="+c.Value)
  154. }
  155. }
  156. if len(parts) == 0 {
  157. return "", ""
  158. }
  159. return "", strings.Join(parts, "; ")
  160. }
  161. // truncateText 截断长文本到 maxRunes 个字符(按 rune 计,避免切断多字节)
  162. func truncateText(s string, maxRunes int) string {
  163. r := []rune(s)
  164. if len(r) <= maxRunes {
  165. return s
  166. }
  167. return string(r[:maxRunes]) + "…"
  168. }
  169. // ------------------------------------------------------------------------
  170. // 备用:B站热门 / 排行榜(无需登录),可作为 cookie 失效时的兜底。
  171. // ------------------------------------------------------------------------
  172. const (
  173. biliPopularURL = "https://api.bilibili.com/x/web-interface/popular"
  174. biliRankURL = "https://api.bilibili.com/x/web-interface/ranking/v2"
  175. )
  176. // biliVideoListPayload 热门 / 排行接口响应结构
  177. type biliVideoListPayload struct {
  178. Code int `json:"code"`
  179. Message string `json:"message"`
  180. Data struct {
  181. List []struct {
  182. Title string `json:"title"` // 视频标题
  183. Bvid string `json:"bvid"` // 视频 bvid
  184. } `json:"list"`
  185. } `json:"data"`
  186. }
  187. // fetchBiliPopular 抓取 B站热门视频(取前 20 条,无需登录)
  188. func fetchBiliPopular() ([]BiliDynamicItem, error) {
  189. return fetchBiliVideoList(biliPopularURL + "?ps=20&pn=1")
  190. }
  191. // fetchBiliRank 抓取 B站排行榜(取前 20 条,无需登录)
  192. func fetchBiliRank() ([]BiliDynamicItem, error) {
  193. return fetchBiliVideoList(biliRankURL + "?rid=0&type=all")
  194. }
  195. func fetchBiliVideoList(apiURL string) ([]BiliDynamicItem, error) {
  196. client := &http.Client{Timeout: 10 * time.Second}
  197. req, err := http.NewRequest(http.MethodGet, apiURL, nil)
  198. if err != nil {
  199. return nil, err
  200. }
  201. 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")
  202. req.Header.Set("Referer", biliHomeURL)
  203. req.Header.Set("Accept", "application/json")
  204. resp, err := client.Do(req)
  205. if err != nil {
  206. return nil, err
  207. }
  208. defer resp.Body.Close()
  209. body, err := io.ReadAll(resp.Body)
  210. if err != nil {
  211. return nil, err
  212. }
  213. var payload biliVideoListPayload
  214. if err := json.Unmarshal(body, &payload); err != nil {
  215. return nil, fmt.Errorf("解析 B站列表失败: %w", err)
  216. }
  217. if payload.Code != 0 {
  218. return nil, fmt.Errorf("B站列表接口错误 code=%d message=%s", payload.Code, payload.Message)
  219. }
  220. items := make([]BiliDynamicItem, 0, len(payload.Data.List))
  221. for _, v := range payload.Data.List {
  222. title := strings.TrimSpace(v.Title)
  223. if title == "" || v.Bvid == "" {
  224. continue
  225. }
  226. items = append(items, BiliDynamicItem{
  227. Source: "B站",
  228. Title: truncateText(title, 120),
  229. URL: "https://www.bilibili.com/video/" + v.Bvid,
  230. })
  231. if len(items) >= 20 {
  232. break
  233. }
  234. }
  235. if len(items) == 0 {
  236. return nil, fmt.Errorf("列表为空")
  237. }
  238. return items, nil
  239. }
  240. // ------------------------------------------------------------------------
  241. // 组合抓取:B站动态(需 cookie)+ 热门兜底。
  242. // ------------------------------------------------------------------------
  243. // fetchBiliFeed 优先抓关注动态,cookie 为空或失效时回退热门视频。
  244. // 返回 (动态列表, 是否使用了兜底, 错误)。
  245. func fetchBiliFeed(cookie string) ([]BiliDynamicItem, bool, error) {
  246. dyn, err := fetchBiliDynamic(cookie)
  247. if err == nil {
  248. return dyn, false, nil // 正常拿到关注动态
  249. }
  250. // 兜底:热门视频
  251. pop, popErr := fetchBiliPopular()
  252. if popErr == nil {
  253. return pop, true, nil // 用热门兜底(动态失败但热门成功)
  254. }
  255. return nil, true, fmt.Errorf("B站动态失败:%v;热门兜底也失败:%v", err, popErr)
  256. }