// ============================================================================
// 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...))
}
// 煎蛋首页文章标题:
var jandanTitleRe = regexp.MustCompile(``)
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)
}