| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390 |
- // ============================================================================
- // newsfeed.go — 新闻聚合
- //
- // 多来源新闻抓取(煎蛋、头条热榜、腾讯科技/财经、Solidot),
- // 支持关键词命中置顶与已查看状态。
- // ============================================================================
- package main
- import (
- "crypto/md5"
- "crypto/sha1"
- "encoding/json"
- "encoding/xml"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "regexp"
- "sort"
- "strings"
- "sync"
- "time"
- )
- // 新闻来源接口地址
- const (
- jandanURL = "https://jandan.net/" // 煎蛋首页(HTML)
- toutiaoURL = "https://www.toutiao.com/hot-event/hot-board/?origin=toutiao_pc" // 头条热榜(JSON)
- tencentURL = "https://r.inews.qq.com/web_feed/getPCList" // 腾讯新闻频道列表(JSON)
- // 腾讯频道 id:来自频道页 channelNav 配置(news_news_tech / news_news_finance)
- tencentTechID = "news_news_tech"
- tencentFinanceID = "news_news_finance"
- tencentSourceName = "腾讯"
- solidotURL = "https://www.solidot.org/index.rss" // 奇客 Solidot 全文 RSS(XML)
- solidotSourceName = "Solidot"
- cailianSourceName = "财联社" // 财联社来源显示名
- cailianDepthID = "1000" // 深度频道 id(对应 /depth?id=1000)
- cailianDepthURL = "https://www.cls.cn/depth?id=1000" // 深度频道页面地址
- )
- // NewsItem 一条新闻标题,供「新闻」页与新闻滚动条使用
- type NewsItem struct {
- Source string `json:"source"` // 来源:煎蛋 / 头条 / 腾讯
- Title string `json:"title"` // 标题
- URL string `json:"url"` // 详情页地址
- Matched bool `json:"matched"` // 是否命中关注关键词
- }
- // fetchNews 并行抓取各来源新闻,按关键词标记后返回(命中的排前面)
- func fetchNews(keywords string) ([]NewsItem, error) {
- var mu sync.Mutex
- var wg sync.WaitGroup
- items := make([]NewsItem, 0, 60)
- errs := make([]string, 0, 5)
- // 各来源并发抓取,失败只记录不中断
- for _, job := range []struct {
- source string
- fetch func() ([]NewsItem, error)
- }{
- {"煎蛋", fetchJandan},
- {"头条", fetchToutiao},
- {"腾讯科技", func() ([]NewsItem, error) { return fetchTencentChannel(tencentTechID) }},
- {"腾讯财经", func() ([]NewsItem, error) { return fetchTencentChannel(tencentFinanceID) }},
- {"Solidot", fetchSolidot},
- {"财联社", fetchCailian},
- } {
- wg.Add(1)
- go func(source string, fetch func() ([]NewsItem, error)) {
- defer wg.Done()
- list, err := fetch()
- mu.Lock()
- defer mu.Unlock()
- if err != nil {
- errs = append(errs, fmt.Sprintf("%s: %v", source, err))
- return
- }
- items = append(items, list...)
- }(job.source, job.fetch)
- }
- wg.Wait()
- markNewsMatched(items, keywords) // 按关键词标记命中项
- sortNews(items) // 命中项排在前面
- if len(items) == 0 && len(errs) > 0 {
- return nil, fmt.Errorf("全部来源抓取失败(%s)", strings.Join(errs, ";"))
- }
- return items, nil
- }
- // markNewsMatched 按逗号 / 空格分隔的关键词标记标题命中项(不区分大小写)
- func markNewsMatched(items []NewsItem, keywords string) {
- words := strings.FieldsFunc(keywords, func(r rune) bool {
- return r == ',' || r == ',' || r == ' '
- })
- for i := range items {
- lower := strings.ToLower(items[i].Title)
- for _, w := range words {
- if w = strings.TrimSpace(w); w != "" && strings.Contains(lower, strings.ToLower(w)) {
- items[i].Matched = true
- break
- }
- }
- }
- }
- // sortNews 稳定排序:命中关键词的排前面,其余保持原顺序
- func sortNews(items []NewsItem) {
- matched := make([]NewsItem, 0, len(items))
- others := make([]NewsItem, 0, len(items))
- for _, item := range items {
- if item.Matched {
- matched = append(matched, item)
- } else {
- others = append(others, item)
- }
- }
- copy(items, append(matched, others...))
- }
- // 煎蛋首页文章标题:<h2 class="post-title"><a href="/p/123/">标题</a></h2>
- var jandanTitleRe = regexp.MustCompile(`<h2[^>]*>\s*<a[^>]+href="([^"]+)"[^>]*>([\s\S]*?)</a>\s*</h2>`)
- var htmlTagRe = regexp.MustCompile(`<[^>]+>`) // 剥掉标题里残留的内联标签
- // fetchJandan 抓取煎蛋首页文章标题(取前 20 条)
- func fetchJandan() ([]NewsItem, error) {
- body, err := httpGetText(jandanURL) // 煎蛋页面为 UTF-8
- if err != nil {
- return nil, err
- }
- matches := jandanTitleRe.FindAllStringSubmatch(body, 40)
- items := make([]NewsItem, 0, len(matches))
- seen := make(map[string]bool)
- for _, m := range matches {
- title := strings.TrimSpace(htmlTagRe.ReplaceAllString(m[2], ""))
- url := strings.TrimSpace(m[1])
- if strings.HasPrefix(url, "//") {
- url = "https:" + url // 协议相对地址补全
- }
- if strings.HasPrefix(url, "/") {
- url = "https://jandan.net" + url // 站内相对地址补全
- }
- if title == "" || seen[url] {
- continue
- }
- seen[url] = true
- items = append(items, NewsItem{Source: "煎蛋", Title: title, URL: url})
- if len(items) >= 20 {
- break
- }
- }
- if len(items) == 0 {
- return nil, fmt.Errorf("未解析到文章标题")
- }
- return items, nil
- }
- // toutiaoPayload 头条热榜接口的响应结构
- type toutiaoPayload struct {
- Data []struct {
- Title string `json:"Title"`
- URL string `json:"Url"`
- } `json:"data"`
- }
- // fetchToutiao 抓取头条热榜(取前 30 条)
- func fetchToutiao() ([]NewsItem, error) {
- var payload toutiaoPayload
- if err := httpGetJSON2(toutiaoURL, &payload); err != nil {
- return nil, err
- }
- items := make([]NewsItem, 0, len(payload.Data))
- for _, entry := range payload.Data {
- if entry.Title == "" {
- continue
- }
- items = append(items, NewsItem{Source: "头条", Title: entry.Title, URL: entry.URL})
- if len(items) >= 30 {
- break
- }
- }
- if len(items) == 0 {
- return nil, fmt.Errorf("热榜为空")
- }
- return items, nil
- }
- // tencentPayload 腾讯新闻频道接口的响应结构
- type tencentPayload struct {
- Data []struct {
- Title string `json:"title"` // 标题
- LinkInfo struct {
- ShareURL string `json:"share_url"` // 详情页地址
- } `json:"link_info"`
- } `json:"data"`
- }
- // fetchTencentChannel 抓取腾讯新闻指定频道(取前 20 条)
- // 接口需要 base_req[from]=pc 形式的嵌套参数,否则返回 empty from
- func fetchTencentChannel(channelID string) ([]NewsItem, error) {
- url := fmt.Sprintf("%s?base_req%%5Bfrom%%5D=pc&channel_id=%s&flush_num=1&item_count=20",
- tencentURL, channelID)
- var payload tencentPayload
- if err := httpGetJSON2(url, &payload); err != nil {
- return nil, err
- }
- items := make([]NewsItem, 0, len(payload.Data))
- for _, entry := range payload.Data {
- title := strings.TrimSpace(entry.Title)
- link := strings.TrimSpace(entry.LinkInfo.ShareURL)
- if title == "" || link == "" {
- continue
- }
- items = append(items, NewsItem{Source: tencentSourceName, Title: title, URL: link})
- if len(items) >= 20 {
- break
- }
- }
- if len(items) == 0 {
- return nil, fmt.Errorf("频道列表为空")
- }
- return items, nil
- }
- // solidotRSS 奇客 Solidot RSS 的 XML 结构
- type solidotRSS struct {
- Channel struct {
- Items []struct {
- Title string `xml:"title"` // 标题
- Link string `xml:"link"` // 详情页地址
- } `xml:"item"`
- } `xml:"channel"`
- }
- // fetchSolidot 抓取奇客 Solidot 的 RSS 新闻(取前 20 条)
- func fetchSolidot() ([]NewsItem, error) {
- body, err := httpGetText(solidotURL)
- if err != nil {
- return nil, err
- }
- var rss solidotRSS
- if err := xml.Unmarshal([]byte(body), &rss); err != nil {
- return nil, fmt.Errorf("解析 RSS 失败: %w", err)
- }
- items := make([]NewsItem, 0, 20)
- for _, entry := range rss.Channel.Items {
- title := strings.TrimSpace(entry.Title)
- link := strings.TrimSpace(entry.Link)
- if title == "" || link == "" {
- continue
- }
- items = append(items, NewsItem{Source: solidotSourceName, Title: title, URL: link})
- if len(items) >= 20 {
- break
- }
- }
- if len(items) == 0 {
- return nil, fmt.Errorf("RSS 为空")
- }
- return items, nil
- }
- // ------------------------------------------------------------------------
- // 财联社深度频道
- // ------------------------------------------------------------------------
- // 签名算法(逆向自前端 _app.js 模块 36498 + 37371 + 68908 + 81383):
- // sign = MD5( hex(SHA1("k=v&k=v")) )
- // 其中参数按 key 默认字典序(.sort())排列,财联社深度接口固定参数为
- // {os:"web", sv:"8.7.9", app:"CailianpressWeb"},与路径 / 时间无关,可稳定复现。
- func cailianSign() string {
- params := map[string]string{"os": "web", "sv": "8.7.9", "app": "CailianpressWeb"}
- keys := make([]string, 0, len(params))
- for k := range params {
- keys = append(keys, k)
- }
- sort.Strings(keys) // 默认字典序,与前端 Object.keys().sort() 一致
- parts := make([]string, 0, len(keys))
- for _, k := range keys {
- parts = append(parts, k+"="+params[k])
- }
- raw := strings.Join(parts, "&")
- // 先 SHA-1(得到 20 字节原始摘要),再取其 hex 字符串(40 个 ASCII 字符)做 MD5
- sha1Bytes := sha1.Sum([]byte(raw))
- sha1Hex := fmt.Sprintf("%x", sha1Bytes) // 40 字符小写 hex
- return fmt.Sprintf("%x", md5.Sum([]byte(sha1Hex)))
- }
- // cailianPayload 财联社深度接口响应结构(只取需要的字段)
- type cailianPayload struct {
- Errno int `json:"errno"`
- Data struct {
- DepthList []struct {
- ID int64 `json:"id"` // 文章 id,用于拼详情页地址
- Title string `json:"title"` // 标题
- } `json:"depth_list"`
- } `json:"data"`
- }
- // fetchCailian 抓取财联社深度频道文章(取前 20 条)
- func fetchCailian() ([]NewsItem, error) {
- sign := cailianSign()
- apiURL := fmt.Sprintf("https://www.cls.cn/v3/depth/home/assembled/%s?app=%s&os=%s&sv=%s&sign=%s",
- cailianDepthID, url.QueryEscape("CailianpressWeb"), "web", "8.7.9", sign)
- body, err := httpGetText(apiURL) // 该接口返回 JSON 文本
- if err != nil {
- return nil, err
- }
- var payload cailianPayload
- if err := json.Unmarshal([]byte(body), &payload); err != nil {
- return nil, fmt.Errorf("解析响应失败: %w", err)
- }
- if payload.Errno != 0 {
- return nil, fmt.Errorf("接口返回 errno=%d", payload.Errno)
- }
- items := make([]NewsItem, 0, len(payload.Data.DepthList))
- for _, entry := range payload.Data.DepthList {
- title := strings.TrimSpace(entry.Title)
- if title == "" {
- continue
- }
- items = append(items, NewsItem{
- Source: cailianSourceName,
- Title: title,
- URL: fmt.Sprintf("https://www.cls.cn/detail/%d", entry.ID),
- })
- if len(items) >= 20 {
- break
- }
- }
- if len(items) == 0 {
- return nil, fmt.Errorf("深度列表为空")
- }
- return items, nil
- }
- // httpGetText 发起 GET 请求并返回响应文本(检查状态码,非 2xx 或空 body 均返回错误)
- func httpGetText(url string) (string, error) {
- resp, err := newsClient(url)
- if err != nil {
- return "", err
- }
- defer resp.Body.Close()
- if resp.StatusCode < 200 || resp.StatusCode > 299 {
- return "", fmt.Errorf("HTTP %d %s", resp.StatusCode, resp.Status) // 限流 / 鉴权失败等,避免误报为解析错误
- }
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return "", err
- }
- if len(body) == 0 {
- return "", fmt.Errorf("响应为空")
- }
- return string(body), nil
- }
- // httpGetJSON2 发起 GET 请求并解析 JSON
- func httpGetJSON2(url string, target any) error {
- resp, err := newsClient(url)
- if err != nil {
- return err
- }
- defer resp.Body.Close()
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return err
- }
- return json.Unmarshal(body, target)
- }
- // newsClient 发起带 UA 的 GET 请求(部分站点校验 User-Agent)
- func newsClient(url string) (*http.Response, error) {
- client := &http.Client{Timeout: 10 * time.Second}
- req, err := http.NewRequest(http.MethodGet, url, 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")
- return client.Do(req)
- }
|