跳转至

crates\nfarms-core\src\today.rs

源码路径:crates\nfarms-core\src\today.rs · 334 行

//! `Viewer › Today's Info`:今天有什么。
//!
//! # 数据源是**探过**的,不是猜的
//!
//! 这个模块的每个 URL 都是在本机实际请求、看过响应体之后才写进来的。
//! 值得记下来的是:**三个「HTTP 200」其实都不可用**:
//!
//! | 源 | 实际返回 |
//! |---|---|
//! | 36kr `/feed` | 25 KB 的 **HTML 单页应用外壳**,不是 RSS(解析出 0 条) |
//! | 中国天气网 `data/sk/…html` | 那个 JSON 接口**已经没了**,现在是 JS + 定位 |
//! | 聚合数据油价 | `{"status":"101","msg":"APPKEY为空"}` —— **要 API key** |
//!
//! **能拿到内容的 200 才算数。** 下面只用验过能解析的源。
//!
//! # 油价:没有接入,而且这是有意的
//!
//! 试过的都不行:聚合数据要 key;qiyoujiage / youjia.cn 是 **SSL 信任失败**
//! (Schannel 拒绝);chemcp 只有 2.4 KB 的 JS 壳;汽车之家油价页解析不出价格。
//!
//! 所以油价这块**不画假卡片** —— 界面上如实写「未接入」并说明原因。
//! 编一个数字出来比空着更糟:油价是有人会照着去加油的。

use std::time::Duration;

/// 一条资讯。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Item {
    /// 标题(或热搜词)
    pub title: String,
    /// 原文链接。热搜词没有链接,为空串。
    pub link: String,
    /// 来源标识,如 `少数派` / `百度热搜`
    pub source: &'static str,
    /// 时间。RSS 给的是 RFC822 原文;热搜没有时间,为空串。
    pub time: String,
}

const UA: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) NFarmsHUB/0.1";

fn agent() -> ureq::Agent {
    ureq::Agent::config_builder()
        .timeout_global(Some(Duration::from_secs(20)))
        .build()
        .into()
}

fn get(url: &str) -> Result<String, String> {
    let mut r = agent()
        .get(url)
        .header("User-Agent", UA)
        .call()
        .map_err(|e| e.to_string())?;
    r.body_mut()
        .read_to_string()
        .map_err(|e| format!("读响应失败: {e}"))
}

/// 取一个标签之间的文本。找不到返回 `None`。
fn tag(s: &str, name: &str) -> Option<String> {
    let open = format!("<{name}>");
    let close = format!("</{name}>");
    let i = s.find(&open)? + open.len();
    let j = s[i..].find(&close)? + i;
    Some(decode(&s[i..j]))
}

/// 去掉 CDATA 包装、XML 实体,并压掉首尾空白。
fn decode(s: &str) -> String {
    let t = s.trim();
    let t = t
        .strip_prefix("<![CDATA[")
        .and_then(|x| x.strip_suffix("]]>"))
        .unwrap_or(t);
    t.trim()
        .replace("&lt;", "<")
        .replace("&gt;", ">")
        .replace("&quot;", "\"")
        .replace("&#39;", "'")
        // `&amp;` **必须最后**替换:先换它的话,`&amp;lt;` 会被拆成 `<`,
        // 而那本来该显示成字面量 `&lt;`。
        .replace("&amp;", "&")
}

/// 解析 RSS:按 `<item>` 切块。
///
/// 不能直接全局找 `<title>` —— 频道自己也有一个 `<title>`(少数派的那个就是
/// "少数派"),混进来会多出一条不是新闻的条目。
pub fn parse_rss(xml: &str, source: &'static str) -> Vec<Item> {
    let mut out = Vec::new();
    for chunk in xml.split("<item>").skip(1) {
        let body = chunk.split("</item>").next().unwrap_or("");
        let Some(title) = tag(body, "title") else {
            continue;
        };
        if title.is_empty() {
            continue;
        }
        out.push(Item {
            title,
            link: tag(body, "link").unwrap_or_default(),
            source,
            time: tag(body, "pubDate").unwrap_or_default(),
        });
    }
    out
}

/// 从百度热搜页里抠出热词。
///
/// 页面里形如 `"word":"机器狗第1次进哀牢山…"`。用扫描而不是正则:
/// 少一个依赖,而且这种"前缀 + 到下一个引号"的形状用 `find` 更直白。
pub fn parse_baidu_hot(html: &str) -> Vec<Item> {
    const NEEDLE: &str = "\"word\":\"";
    let mut out = Vec::new();
    let mut rest = html;
    while let Some(i) = rest.find(NEEDLE) {
        rest = &rest[i + NEEDLE.len()..];
        let Some(j) = rest.find('"') else { break };
        let word = &rest[..j];
        if !word.is_empty() {
            out.push(Item {
                title: decode(word),
                link: String::new(),
                source: "百度热搜",
                time: String::new(),
            });
        }
        rest = &rest[j..];
    }
    out
}

/// 少数派 RSS 的地址。
///
/// **注意它不等于 36kr 那个**:36kr 的 `/feed` 现在返回 HTML 外壳,
/// 所以这里只用少数派。
pub const SSPAI_FEED: &str = "https://sspai.com/feed";
/// 百度热搜。
pub const BAIDU_HOT: &str = "https://top.baidu.com/board?tab=realtime";

/// 各源最多取几条。热搜有 50 多条,全画出来这一页就没法看了。
pub const PER_SOURCE: usize = 12;

/// 抓所有源。
///
/// **单独一个源挂掉不影响别的** —— 每源各自 try,收集成功的部分。
/// 全挂才返回 `Err`。理由是这一页的价值在于"今天有什么",
/// 少数派挂了但热搜还在,那条也值得显示。
pub fn fetch_all() -> Result<Vec<Item>, String> {
    let mut items = Vec::new();
    let mut errs = Vec::new();

    match get(SSPAI_FEED).map(|b| parse_rss(&b, "少数派")) {
        Ok(mut v) => {
            v.truncate(PER_SOURCE);
            items.extend(v);
        }
        Err(e) => errs.push(format!("少数派: {e}")),
    }

    match get(BAIDU_HOT).map(|b| parse_baidu_hot(&b)) {
        Ok(mut v) => {
            v.truncate(PER_SOURCE);
            items.extend(v);
        }
        Err(e) => errs.push(format!("百度热搜: {e}")),
    }

    if items.is_empty() {
        return Err(if errs.is_empty() {
            "所有源都返回了空内容".to_string()
        } else {
            errs.join("; ")
        });
    }
    Ok(items)
}

/// 油价为什么是空的 —— 界面上要**原样显示**这段话。
///
/// 写成一个常量而不是散在界面代码里:它是"我没有唬你"的凭据,
/// 应该和抓取逻辑放在一起,谁改数据源谁就要来改这一段。
pub const FUEL_NOTE: &str = "油价未接入 —— 公开源逐个试过,原因都查清了:\
(1) qiyoujiage.com 证书对不上:服务器给的是 *.cdn.myqcloud.com,不含该域名,被 CDN 挡了;\
(2) youjia.cn 的证书只对 *.4399en.com 有效,同样对不上;\
(3) youjia.chemcp.com 能连上,但正文是 base64 图片,抠不出价格;\
(4) 聚合数据那类接口要 API key。\
**这一栏宁可空着,也不编一个数字** —— 油价是有人会照着去加油的东西。";

#[cfg(test)]
mod tests {
    use super::*;

    /// 真实的少数派 RSS 片段(从本机实际响应里抄的)。
    const RSS: &str = r#"<?xml version="1.0"?>
<rss><channel>
<title>少数派</title>
<item>
  <title>社区速递 161 | 小米手表 S5、EVA 联名香水与换机热议</title>
  <link>https://sspai.com/post/90001</link>
  <pubDate>Tue, 13 Oct 2026 17:00:00 +0800</pubDate>
</item>
<item>
  <title><![CDATA[App+1 | 我做了一个精力管理 App]]></title>
  <link>https://sspai.com/post/90002</link>
  <pubDate>Tue, 13 Oct 2026 10:00:00 +0800</pubDate>
</item>
</channel></rss>"#;

    #[test]
    fn rss_parses_items_and_skips_the_channel_title() {
        let v = parse_rss(RSS, "少数派");
        assert_eq!(v.len(), 2, "频道自己的 <title> 不该算一条: {v:?}");
        assert_eq!(v[0].link, "https://sspai.com/post/90001");
        assert!(v[0].time.starts_with("Tue, 13 Oct"));
        // CDATA 要去掉包装
        assert_eq!(v[1].title, "App+1 | 我做了一个精力管理 App");
    }

    /// 百度热搜的真实形状。
    #[test]
    fn baidu_hot_extracts_words() {
        let html = r#"{"data":{"cards":[{"content":[
            {"word":"机器狗第1次进哀牢山就拍到冥界之花"},
            {"word":"33岁销冠确诊胃癌晚期后突然被辞退"}
        ]}]}}"#;
        let v = parse_baidu_hot(html);
        assert_eq!(v.len(), 2);
        assert_eq!(v[0].title, "机器狗第1次进哀牢山就拍到冥界之花");
        assert_eq!(v[0].source, "百度热搜");
    }

    /// `&amp;` 必须最后换 —— 先换它会把 **双转义**的 `&amp;lt;` 拆成 `<`,
    /// 而那个本来该显示成字面量。
    #[test]
    fn ampersand_is_unescaped_last() {
        assert_eq!(decode("A &amp;lt; B"), "A &lt; B");
        assert_eq!(decode("C &amp; D"), "C & D");
        assert_eq!(decode("E &lt; F"), "E < F");
    }

    /// 空响应 / 坏响应不能 panic,也不能吐出一条空标题。
    #[test]
    fn broken_input_yields_nothing_rather_than_panicking() {
        assert!(parse_rss("", "x").is_empty());
        assert!(parse_rss("<rss><channel></channel></rss>", "x").is_empty());
        assert!(parse_rss("<item><title></title></item>", "x").is_empty());
        assert!(parse_baidu_hot("").is_empty());
        assert!(parse_baidu_hot("\"word\":\"\"").is_empty());
        // 引号没闭合也不能死循环
        assert!(parse_baidu_hot("\"word\":\"没闭合").is_empty());
    }

/// 油价数据源探测 —— **手动跑**,用程序自己的 HTTP 栈(rustls)。
    ///
    /// 为什么要单独一条:PowerShell 的 `Invoke-WebRequest` 走 **Schannel**,
    /// 而这里走 **rustls**。两个 TLS 实现对证书链的要求不一样 ——
    /// 之前 PowerShell 报「SSL 信任失败」的几个站,从程序里可能是能拿到的。
    /// 所以**不能拿 PowerShell 的失败当结论**。
    ///
    ///     cargo test -p nfarms-core today::tests::probe_fuel_sources -- --ignored --nocapture
    #[test]
    #[ignore = "联网探测,手动跑"]
    fn probe_fuel_sources() {
        let urls = [
            // 用户给的两个
            "https://youjia.abapi.cn/",
            "https://cx.sinopecsales.com/yjkqiantai/core/main",
            // 留几个已知结论的做对照:它们**失败**,所以这两条一旦成功就很显眼
            "https://www.qiyoujiage.com/shanghai.shtml",
            "http://youjia.chemcp.com/",
        ];
        for u in urls {
            match get(u) {
                Ok(b) => {
                    let head = b.trim_start();
                    let kind = if head.starts_with('{') || head.starts_with('[') {
                        "JSON"
                    } else if head.to_ascii_lowercase().contains("<!doctype") || head.to_ascii_lowercase().contains("<html") {
                        "HTML"
                    } else {
                        "其它"
                    };
                    println!("[OK]   {u}");
                    println!("       {} 字节,类型={kind},含\"92\"={}", b.len(), b.contains("92"));
                    // 前 200 字节:一眼看出是数据还是空壳
                    let head200: String = head.chars().take(200).collect();
                    println!("       头: {}", head200.replace(['\n', '\r'], " "));
                    // 把每一处「92号」附近的原文打出来 —— 解析器要照着这个写,
                    // 而不是照着我猜的结构写。
                    // 按**关键词**找,而不是按"92":abapi 那页的 92 全在 meta 里,
                    // 价格在别处。中石化那页的数据字段是 `gas92`,按 92 找只会
                    // 找到拼 HTML 的代码。
                    let needle = if u.contains("sinopec") { "gas92" } else { "92" };
                    println!("       (找 {needle})");
                    let mut shown = 0;
                    let mut from = 0usize;
                    while shown < 3 {
                        let Some(rel) = b[from..].find(needle) else { break };
                        let i = from + rel;
                        // **`get` 而不是直接切片。** 中文页面里按字节下标切片
                        // 很容易落到多字节字符中间 —— 那会 panic,
                        // 而症状是"抓取莫名其妙地崩"。
                        let a = i.saturating_sub(120);
                        let z = (i + 220).min(b.len());
                        let snip = match b.get(a..z) {
                            Some(x) => x.replace(['\n', '\r'], " "),
                            None => {
                                // 边界不齐就退一步:从字符边界取
                                let lo = (0..=a).rev().find(|k| b.is_char_boundary(*k)).unwrap_or(0);
                                let hi = (z..=b.len()).find(|k| b.is_char_boundary(*k)).unwrap_or(b.len());
                                b.get(lo..hi).unwrap_or("").replace(['\n', '\r'], " ")
                            }
                        };
                        println!("       [{shown}] …{snip}…");
                        from = i + needle.len();
                        shown += 1;
                    }
                }
                Err(e) => println!("[FAIL] {u}\n       {e}"),
            }
        }
    }
    /// 油价那段说明必须承认「没有」,不能出现一个像价格的数字。
    #[test]
    fn the_fuel_note_says_it_is_not_connected() {
        assert!(FUEL_NOTE.contains("未接入"));
        assert!(FUEL_NOTE.contains("API key"));
        // 而且要给出**具体**原因,不能只说"不可用" —— 含糊的理由下次还会有人再试一遍
        assert!(FUEL_NOTE.contains("证书"));
    }
}