// ============================================================================ // 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(`]*>\s*]+href="([^"]+)"[^>]*>([\s\S]*?)\s*`) 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) }