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("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
// `&` **必须最后**替换:先换它的话,`&lt;` 会被拆成 `<`,
// 而那本来该显示成字面量 `<`。
.replace("&", "&")
}
/// 解析 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, "百度热搜");
}
/// `&` 必须最后换 —— 先换它会把 **双转义**的 `&lt;` 拆成 `<`,
/// 而那个本来该显示成字面量。
#[test]
fn ampersand_is_unescaped_last() {
assert_eq!(decode("A &lt; B"), "A < B");
assert_eq!(decode("C & D"), "C & D");
assert_eq!(decode("E < 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("证书"));
}
}