newsfeed.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. // ============================================================================
  2. // newsfeed.go — 新闻聚合
  3. //
  4. // 多来源新闻抓取(煎蛋、头条热榜、腾讯科技/财经、Solidot),
  5. // 支持关键词命中置顶与已查看状态。
  6. // ============================================================================
  7. package main
  8. import (
  9. "crypto/md5"
  10. "crypto/sha1"
  11. "encoding/json"
  12. "encoding/xml"
  13. "fmt"
  14. "io"
  15. "net/http"
  16. "net/url"
  17. "regexp"
  18. "sort"
  19. "strings"
  20. "sync"
  21. "time"
  22. )
  23. // 新闻来源接口地址
  24. const (
  25. jandanURL = "https://jandan.net/" // 煎蛋首页(HTML)
  26. toutiaoURL = "https://www.toutiao.com/hot-event/hot-board/?origin=toutiao_pc" // 头条热榜(JSON)
  27. tencentURL = "https://r.inews.qq.com/web_feed/getPCList" // 腾讯新闻频道列表(JSON)
  28. // 腾讯频道 id:来自频道页 channelNav 配置(news_news_tech / news_news_finance)
  29. tencentTechID = "news_news_tech"
  30. tencentFinanceID = "news_news_finance"
  31. tencentSourceName = "腾讯"
  32. solidotURL = "https://www.solidot.org/index.rss" // 奇客 Solidot 全文 RSS(XML)
  33. solidotSourceName = "Solidot"
  34. cailianSourceName = "财联社" // 财联社来源显示名
  35. cailianDepthID = "1000" // 深度频道 id(对应 /depth?id=1000)
  36. cailianDepthURL = "https://www.cls.cn/depth?id=1000" // 深度频道页面地址
  37. )
  38. // NewsItem 一条新闻标题,供「新闻」页与新闻滚动条使用
  39. type NewsItem struct {
  40. Source string `json:"source"` // 来源:煎蛋 / 头条 / 腾讯
  41. Title string `json:"title"` // 标题
  42. URL string `json:"url"` // 详情页地址
  43. Matched bool `json:"matched"` // 是否命中关注关键词
  44. }
  45. // fetchNews 并行抓取各来源新闻,按关键词标记后返回(命中的排前面)
  46. func fetchNews(keywords string) ([]NewsItem, error) {
  47. var mu sync.Mutex
  48. var wg sync.WaitGroup
  49. items := make([]NewsItem, 0, 60)
  50. errs := make([]string, 0, 5)
  51. // 各来源并发抓取,失败只记录不中断
  52. for _, job := range []struct {
  53. source string
  54. fetch func() ([]NewsItem, error)
  55. }{
  56. {"煎蛋", fetchJandan},
  57. {"头条", fetchToutiao},
  58. {"腾讯科技", func() ([]NewsItem, error) { return fetchTencentChannel(tencentTechID) }},
  59. {"腾讯财经", func() ([]NewsItem, error) { return fetchTencentChannel(tencentFinanceID) }},
  60. {"Solidot", fetchSolidot},
  61. {"财联社", fetchCailian},
  62. } {
  63. wg.Add(1)
  64. go func(source string, fetch func() ([]NewsItem, error)) {
  65. defer wg.Done()
  66. list, err := fetch()
  67. mu.Lock()
  68. defer mu.Unlock()
  69. if err != nil {
  70. errs = append(errs, fmt.Sprintf("%s: %v", source, err))
  71. return
  72. }
  73. items = append(items, list...)
  74. }(job.source, job.fetch)
  75. }
  76. wg.Wait()
  77. markNewsMatched(items, keywords) // 按关键词标记命中项
  78. sortNews(items) // 命中项排在前面
  79. if len(items) == 0 && len(errs) > 0 {
  80. return nil, fmt.Errorf("全部来源抓取失败(%s)", strings.Join(errs, ";"))
  81. }
  82. return items, nil
  83. }
  84. // markNewsMatched 按逗号 / 空格分隔的关键词标记标题命中项(不区分大小写)
  85. func markNewsMatched(items []NewsItem, keywords string) {
  86. words := strings.FieldsFunc(keywords, func(r rune) bool {
  87. return r == ',' || r == ',' || r == ' '
  88. })
  89. for i := range items {
  90. lower := strings.ToLower(items[i].Title)
  91. for _, w := range words {
  92. if w = strings.TrimSpace(w); w != "" && strings.Contains(lower, strings.ToLower(w)) {
  93. items[i].Matched = true
  94. break
  95. }
  96. }
  97. }
  98. }
  99. // sortNews 稳定排序:命中关键词的排前面,其余保持原顺序
  100. func sortNews(items []NewsItem) {
  101. matched := make([]NewsItem, 0, len(items))
  102. others := make([]NewsItem, 0, len(items))
  103. for _, item := range items {
  104. if item.Matched {
  105. matched = append(matched, item)
  106. } else {
  107. others = append(others, item)
  108. }
  109. }
  110. copy(items, append(matched, others...))
  111. }
  112. // 煎蛋首页文章标题:<h2 class="post-title"><a href="/p/123/">标题</a></h2>
  113. var jandanTitleRe = regexp.MustCompile(`<h2[^>]*>\s*<a[^>]+href="([^"]+)"[^>]*>([\s\S]*?)</a>\s*</h2>`)
  114. var htmlTagRe = regexp.MustCompile(`<[^>]+>`) // 剥掉标题里残留的内联标签
  115. // fetchJandan 抓取煎蛋首页文章标题(取前 20 条)
  116. func fetchJandan() ([]NewsItem, error) {
  117. body, err := httpGetText(jandanURL) // 煎蛋页面为 UTF-8
  118. if err != nil {
  119. return nil, err
  120. }
  121. matches := jandanTitleRe.FindAllStringSubmatch(body, 40)
  122. items := make([]NewsItem, 0, len(matches))
  123. seen := make(map[string]bool)
  124. for _, m := range matches {
  125. title := strings.TrimSpace(htmlTagRe.ReplaceAllString(m[2], ""))
  126. url := strings.TrimSpace(m[1])
  127. if strings.HasPrefix(url, "//") {
  128. url = "https:" + url // 协议相对地址补全
  129. }
  130. if strings.HasPrefix(url, "/") {
  131. url = "https://jandan.net" + url // 站内相对地址补全
  132. }
  133. if title == "" || seen[url] {
  134. continue
  135. }
  136. seen[url] = true
  137. items = append(items, NewsItem{Source: "煎蛋", Title: title, URL: url})
  138. if len(items) >= 20 {
  139. break
  140. }
  141. }
  142. if len(items) == 0 {
  143. return nil, fmt.Errorf("未解析到文章标题")
  144. }
  145. return items, nil
  146. }
  147. // toutiaoPayload 头条热榜接口的响应结构
  148. type toutiaoPayload struct {
  149. Data []struct {
  150. Title string `json:"Title"`
  151. URL string `json:"Url"`
  152. } `json:"data"`
  153. }
  154. // fetchToutiao 抓取头条热榜(取前 30 条)
  155. func fetchToutiao() ([]NewsItem, error) {
  156. var payload toutiaoPayload
  157. if err := httpGetJSON2(toutiaoURL, &payload); err != nil {
  158. return nil, err
  159. }
  160. items := make([]NewsItem, 0, len(payload.Data))
  161. for _, entry := range payload.Data {
  162. if entry.Title == "" {
  163. continue
  164. }
  165. items = append(items, NewsItem{Source: "头条", Title: entry.Title, URL: entry.URL})
  166. if len(items) >= 30 {
  167. break
  168. }
  169. }
  170. if len(items) == 0 {
  171. return nil, fmt.Errorf("热榜为空")
  172. }
  173. return items, nil
  174. }
  175. // tencentPayload 腾讯新闻频道接口的响应结构
  176. type tencentPayload struct {
  177. Data []struct {
  178. Title string `json:"title"` // 标题
  179. LinkInfo struct {
  180. ShareURL string `json:"share_url"` // 详情页地址
  181. } `json:"link_info"`
  182. } `json:"data"`
  183. }
  184. // fetchTencentChannel 抓取腾讯新闻指定频道(取前 20 条)
  185. // 接口需要 base_req[from]=pc 形式的嵌套参数,否则返回 empty from
  186. func fetchTencentChannel(channelID string) ([]NewsItem, error) {
  187. url := fmt.Sprintf("%s?base_req%%5Bfrom%%5D=pc&channel_id=%s&flush_num=1&item_count=20",
  188. tencentURL, channelID)
  189. var payload tencentPayload
  190. if err := httpGetJSON2(url, &payload); err != nil {
  191. return nil, err
  192. }
  193. items := make([]NewsItem, 0, len(payload.Data))
  194. for _, entry := range payload.Data {
  195. title := strings.TrimSpace(entry.Title)
  196. link := strings.TrimSpace(entry.LinkInfo.ShareURL)
  197. if title == "" || link == "" {
  198. continue
  199. }
  200. items = append(items, NewsItem{Source: tencentSourceName, Title: title, URL: link})
  201. if len(items) >= 20 {
  202. break
  203. }
  204. }
  205. if len(items) == 0 {
  206. return nil, fmt.Errorf("频道列表为空")
  207. }
  208. return items, nil
  209. }
  210. // solidotRSS 奇客 Solidot RSS 的 XML 结构
  211. type solidotRSS struct {
  212. Channel struct {
  213. Items []struct {
  214. Title string `xml:"title"` // 标题
  215. Link string `xml:"link"` // 详情页地址
  216. } `xml:"item"`
  217. } `xml:"channel"`
  218. }
  219. // fetchSolidot 抓取奇客 Solidot 的 RSS 新闻(取前 20 条)
  220. func fetchSolidot() ([]NewsItem, error) {
  221. body, err := httpGetText(solidotURL)
  222. if err != nil {
  223. return nil, err
  224. }
  225. var rss solidotRSS
  226. if err := xml.Unmarshal([]byte(body), &rss); err != nil {
  227. return nil, fmt.Errorf("解析 RSS 失败: %w", err)
  228. }
  229. items := make([]NewsItem, 0, 20)
  230. for _, entry := range rss.Channel.Items {
  231. title := strings.TrimSpace(entry.Title)
  232. link := strings.TrimSpace(entry.Link)
  233. if title == "" || link == "" {
  234. continue
  235. }
  236. items = append(items, NewsItem{Source: solidotSourceName, Title: title, URL: link})
  237. if len(items) >= 20 {
  238. break
  239. }
  240. }
  241. if len(items) == 0 {
  242. return nil, fmt.Errorf("RSS 为空")
  243. }
  244. return items, nil
  245. }
  246. // ------------------------------------------------------------------------
  247. // 财联社深度频道
  248. // ------------------------------------------------------------------------
  249. // 签名算法(逆向自前端 _app.js 模块 36498 + 37371 + 68908 + 81383):
  250. // sign = MD5( hex(SHA1("k=v&k=v")) )
  251. // 其中参数按 key 默认字典序(.sort())排列,财联社深度接口固定参数为
  252. // {os:"web", sv:"8.7.9", app:"CailianpressWeb"},与路径 / 时间无关,可稳定复现。
  253. func cailianSign() string {
  254. params := map[string]string{"os": "web", "sv": "8.7.9", "app": "CailianpressWeb"}
  255. keys := make([]string, 0, len(params))
  256. for k := range params {
  257. keys = append(keys, k)
  258. }
  259. sort.Strings(keys) // 默认字典序,与前端 Object.keys().sort() 一致
  260. parts := make([]string, 0, len(keys))
  261. for _, k := range keys {
  262. parts = append(parts, k+"="+params[k])
  263. }
  264. raw := strings.Join(parts, "&")
  265. // 先 SHA-1(得到 20 字节原始摘要),再取其 hex 字符串(40 个 ASCII 字符)做 MD5
  266. sha1Bytes := sha1.Sum([]byte(raw))
  267. sha1Hex := fmt.Sprintf("%x", sha1Bytes) // 40 字符小写 hex
  268. return fmt.Sprintf("%x", md5.Sum([]byte(sha1Hex)))
  269. }
  270. // cailianPayload 财联社深度接口响应结构(只取需要的字段)
  271. type cailianPayload struct {
  272. Errno int `json:"errno"`
  273. Data struct {
  274. DepthList []struct {
  275. ID int64 `json:"id"` // 文章 id,用于拼详情页地址
  276. Title string `json:"title"` // 标题
  277. } `json:"depth_list"`
  278. } `json:"data"`
  279. }
  280. // fetchCailian 抓取财联社深度频道文章(取前 20 条)
  281. func fetchCailian() ([]NewsItem, error) {
  282. sign := cailianSign()
  283. apiURL := fmt.Sprintf("https://www.cls.cn/v3/depth/home/assembled/%s?app=%s&os=%s&sv=%s&sign=%s",
  284. cailianDepthID, url.QueryEscape("CailianpressWeb"), "web", "8.7.9", sign)
  285. body, err := httpGetText(apiURL) // 该接口返回 JSON 文本
  286. if err != nil {
  287. return nil, err
  288. }
  289. var payload cailianPayload
  290. if err := json.Unmarshal([]byte(body), &payload); err != nil {
  291. return nil, fmt.Errorf("解析响应失败: %w", err)
  292. }
  293. if payload.Errno != 0 {
  294. return nil, fmt.Errorf("接口返回 errno=%d", payload.Errno)
  295. }
  296. items := make([]NewsItem, 0, len(payload.Data.DepthList))
  297. for _, entry := range payload.Data.DepthList {
  298. title := strings.TrimSpace(entry.Title)
  299. if title == "" {
  300. continue
  301. }
  302. items = append(items, NewsItem{
  303. Source: cailianSourceName,
  304. Title: title,
  305. URL: fmt.Sprintf("https://www.cls.cn/detail/%d", entry.ID),
  306. })
  307. if len(items) >= 20 {
  308. break
  309. }
  310. }
  311. if len(items) == 0 {
  312. return nil, fmt.Errorf("深度列表为空")
  313. }
  314. return items, nil
  315. }
  316. // httpGetText 发起 GET 请求并返回响应文本(检查状态码,非 2xx 或空 body 均返回错误)
  317. func httpGetText(url string) (string, error) {
  318. resp, err := newsClient(url)
  319. if err != nil {
  320. return "", err
  321. }
  322. defer resp.Body.Close()
  323. if resp.StatusCode < 200 || resp.StatusCode > 299 {
  324. return "", fmt.Errorf("HTTP %d %s", resp.StatusCode, resp.Status) // 限流 / 鉴权失败等,避免误报为解析错误
  325. }
  326. body, err := io.ReadAll(resp.Body)
  327. if err != nil {
  328. return "", err
  329. }
  330. if len(body) == 0 {
  331. return "", fmt.Errorf("响应为空")
  332. }
  333. return string(body), nil
  334. }
  335. // httpGetJSON2 发起 GET 请求并解析 JSON
  336. func httpGetJSON2(url string, target any) error {
  337. resp, err := newsClient(url)
  338. if err != nil {
  339. return err
  340. }
  341. defer resp.Body.Close()
  342. body, err := io.ReadAll(resp.Body)
  343. if err != nil {
  344. return err
  345. }
  346. return json.Unmarshal(body, target)
  347. }
  348. // newsClient 发起带 UA 的 GET 请求(部分站点校验 User-Agent)
  349. func newsClient(url string) (*http.Response, error) {
  350. client := &http.Client{Timeout: 10 * time.Second}
  351. req, err := http.NewRequest(http.MethodGet, url, nil)
  352. if err != nil {
  353. return nil, err
  354. }
  355. 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")
  356. return client.Do(req)
  357. }