crates\nfarms-core\src\videoparse.rs¶
源码路径:
crates\nfarms-core\src\videoparse.rs· 307 行
//! 视频地址解析(「虾米解析」那一套)—— C# `VideoParse.cs` 的等价移植。
//!
//! 必须说清楚一件事:**这不是自己实现协议**。所谓「解析」是拿页面地址去问
//! 第三方解析服务,再从返回的 HTML 里把真正的 m3u8 / mp4 地址抠出来。
//! 服务挂了、改了页面结构,这里就会失败 —— 所以每个函数都把**诊断信息**
//! 一并返回(`diag`),而不是只给一句「解析失败」。
//!
//! 抠地址用手写的扫描而不是 `regex`:要匹配的形状只有「http(s):// 开头、
//! 以 .m3u8/.mp4/.flv/.ts 结尾(允许带查询串)」这一种,手写一遍比多引一个
//! 依赖划算 —— 和 `tools.rs` 里找版本号是同一个取舍。
use std::time::Duration;
/// 与 C# `VideoParse.Providers` 一一对应。
pub const PROVIDERS: [&str; 2] = ["xmflv(jx 解析)", "hellotik(B 站解析)"];
/// 经典 jx 解析服务。
pub const JX_BASE: &str = "https://jx.xmflv.cc/";
/// hellotik(B 站)是个网页工具:要在浏览器里点,程序只能给你打开页面。
pub const HELLOTIK_URL: &str = "https://www.hellotik.app/zh/bilibili";
/// 能被当成「媒体直链」的扩展名。
const MEDIA_EXT: [&str; 4] = ["m3u8", "mp4", "flv", "ts"];
/// jx 解析服务的请求地址 —— 「jx 的地址长什么样」只在这里写一次。
///
/// 对应 C# `cs-src/VideoParse.cs:43` 的 `ServicePage(0, videoUrl)`。单独抽一层
/// 而不是让 `resolve_direct` 自己 `format!`,是因为移植时那一步恰好被内联了:
/// 同一个 URL 形状有了两份,`service_page` 反过来一个生产调用者都没有(死代码)。
/// 留着两份的代价不是重复,而是**改一处漏一处** —— 将来地址要带 referer 参数时,
/// 「我们请求的地址」和「交给界面打开的地址」会不一致,而界面照旧显示得像成功。
fn jx_request_url(url: &str) -> String {
format!("{JX_BASE}?url={}", url_encode(url.trim()))
}
/// 解析服务的入口页地址(用户可以在浏览器里打开它)。
pub fn service_page(provider: usize, video_url: &str) -> String {
if video_url.trim().is_empty() {
return match provider {
1 => HELLOTIK_URL.to_string(),
_ => JX_BASE.to_string(),
};
}
match provider {
1 => HELLOTIK_URL.to_string(),
_ => jx_request_url(video_url),
}
}
/// 去解析服务要真正的直链。
///
/// 返回 `(直链, 诊断)`,或 `(错误说明, 诊断)`。诊断里带上了
/// HTTP 状态码、响应长度、抽到的候选数量 —— 出问题时能看出是哪一步断的。
pub fn resolve_direct(provider: usize, video_url: &str) -> Result<(String, String), (String, String)> {
let url = video_url.trim();
if url.is_empty() {
return Err(("没填视频页面地址".to_string(), String::new()));
}
if provider == 1 {
// hellotik 是网页工具,没有可编程接口 —— 如实说明,别假装能解析
return Err((
format!("hellotik 是网页工具,不能直接解析;已经在浏览器里打开 {HELLOTIK_URL}"),
String::new(),
));
}
let target = format!("{JX_BASE}?url={}", url_encode(url));
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(20)))
.user_agent("Mozilla/5.0 NFarmsTools-VideoParse/1.0")
.build()
.into();
let (status, body) = match agent.get(&target).call() {
Ok(r) => {
let st = r.status().as_u16();
let text = r.into_body().read_to_string().unwrap_or_default();
(st, text)
}
Err(ureq::Error::StatusCode(code)) => (code, String::new()),
Err(e) => {
return Err((format!("请求解析服务失败: {e}"), format!("GET {target}")));
}
};
let candidates = extract_media_urls(&body);
let diag = format!(
"GET {target}\nHTTP {status},响应 {} 字节,抽到 {} 个候选地址",
body.len(),
candidates.len()
);
if candidates.is_empty() {
return Err((
format!(
"响应里没有找到 m3u8/mp4/flv/ts 地址(HTTP {status},{} 字节)。\
多半是解析服务改版了,或者这个视频它解析不了。",
body.len()
),
diag,
));
}
// 优先 m3u8(通常是不分段的流),否则取第一个
let pick = candidates
.iter()
.find(|u| u.contains(".m3u8"))
.cloned()
.unwrap_or_else(|| candidates[0].clone());
Ok((pick, diag))
}
/// 从一段文本里抠出全部媒体地址(顺序保持出现顺序,已去重)。
pub fn extract_media_urls(text: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let bytes = text.as_bytes();
let mut i = 0usize;
while i < bytes.len() {
// 找 http:// 或 https://
let rest = &text[i..];
let start = if rest.starts_with("https://") {
8
} else if rest.starts_with("http://") {
7
} else {
// 按**字符边界**推进,不是按字节 +1。
// `&text[i..]`(上面那行)是按字符边界切片的,而 i 一旦停在某个
// 多字节字符的中间就会 panic。喂进来的是 jx.xmflv.cc 的**整页 HTML**,
// 中文标题/BOM 是常态而不是边角,所以按字节 +1 等于"有中文就必炸";
// 偏偏这个 panic 又发生在 GUI 的后台线程里,只会表现为界面一片空白
// (静默失效),极难查 —— 详见下方
// `non_ascii_response_does_not_panic_on_byte_wise_advance` 测试。
i += text[i..].chars().next().map(char::len_utf8).unwrap_or(1);
continue;
};
// 从协议头往后扫到「不该出现在 URL 里的字符」
let mut j = i + start;
while j < bytes.len() {
let c = bytes[j];
let stop = matches!(
c,
b'"' | b'\'' | b'<' | b'>' | b'\\' | b' ' | b'\t' | b'\r' | b'\n' | b'`'
);
if stop {
break;
}
j += 1;
}
let candidate = &text[i..j];
if is_media_url(candidate) && !out.iter().any(|u| u == candidate) {
out.push(candidate.to_string());
}
// j 只会停在 ASCII 停止字符上(或串尾),而多字节字符的每个字节都 >= 0x80、
// 不可能命中那个停止集合 —— 所以 j 一定是字符边界,这里直接跳是安全的。
// 当初的 bug 只出在上面按字节推进的 i 上,不在 j。
i = j.max(i + 1);
}
out
}
/// 路径部分以媒体扩展名结尾(允许后面带查询串)。
fn is_media_url(u: &str) -> bool {
let after_scheme = match u.find("://") {
Some(p) => &u[p + 3..],
None => return false,
};
// 去掉 ?query 和 #fragment
let path = after_scheme
.split(['?', '#'])
.next()
.unwrap_or(after_scheme);
let ext = match path.rsplit('.').next() {
Some(e) => e.to_ascii_lowercase(),
None => return false,
};
MEDIA_EXT.contains(&ext.as_str())
}
/// 最小百分号编码:只编码那些会破坏 query 的字符。
///
/// 不用引 `urlencoding` —— 需要编码的就是这几个。
fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 8);
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_media_urls_and_ignores_other_links() {
let html = r#"
<html><body>
<a href="https://example.com/page.html">x</a>
<script>var u="https://cdn.example.com/video/abc.m3u8?sign=1&t=2";</script>
<source src='http://cdn.example.com/movie.mp4'>
<span>https://cdn.example.com/seg/001.ts</span>
</body></html>
"#;
let got = extract_media_urls(html);
assert_eq!(
got,
vec![
"https://cdn.example.com/video/abc.m3u8?sign=1&t=2",
"http://cdn.example.com/movie.mp4",
"https://cdn.example.com/seg/001.ts",
]
);
}
/// 回归:响应体含非 ASCII(中文标题 / UTF-8 BOM)时,`extract_media_urls` 曾经
/// **必然 panic**,而这正是 `resolve_direct` 的主路径输入。
///
/// 病因在**扫描起点**而不是终点:找不到 `http(s)://` 时按**字节** `i += 1`
/// 推进,可 `&text[i..]` 是按**字符边界**切片的。只要有中文/BOM,`i` 早晚停在
/// 某个多字节字符的中间,下一轮切片就
/// `byte index N is not a char boundary`。停止字符集全是 ASCII,所以 `j`
/// 那一侧本来就是安全的 —— 只有 `i` 会脱轨。
///
/// 之所以要专门守这一条:`resolve_direct` 把 jx.xmflv.cc 返回的**整页 HTML**
/// 喂进来,里面必然带中文标题;而 panic 发生在 GUI 的后台线程里,`tx` 一 drop,
/// 界面只会停在「(还没跑过)」,连一句错误提示都没有(见 `nfarms-gui` 的
/// `TryRecvError::Disconnected` 分支)。
#[test]
fn non_ascii_response_does_not_panic_on_byte_wise_advance() {
// ① 中文标题在地址之前 —— 按字节推进会在 '标' 的中间字节上切片
let html = concat!(
"<html><head><title>标题</title></head><body>",
"<script>var u=\"https://cdn.example.com/a.m3u8?sign=1\";</script>",
"</body></html>"
);
assert_eq!(
extract_media_urls(html),
vec!["https://cdn.example.com/a.m3u8?sign=1"]
);
// ② 只有一条 BOM(3 字节):按字节推进时 i=1 就落进它中间
assert!(extract_media_urls("\u{FEFF}").is_empty());
// ③ 中文夹在两条地址之间(用空白分隔 —— 空白是停止字符)
let mixed = "中文说明 https://cdn.example.com/a.mp4 中文 https://cdn.example.com/b.flv";
assert_eq!(
extract_media_urls(mixed),
vec![
"https://cdn.example.com/a.mp4",
"https://cdn.example.com/b.flv",
]
);
// ④ 中文紧跟在地址后面、以 `"` 收尾(真实 HTML 的形状)。
// 这里 j 停在 ASCII 引号上,顺带守住"别把后面的中文吃进候选串"。
let quoted = "<a href=\"https://cdn.example.com/c.ts\">中文</a>";
assert_eq!(
extract_media_urls(quoted),
vec!["https://cdn.example.com/c.ts"]
);
}
#[test]
fn dedupes_repeated_urls() {
let t = "https://a/x.mp4 and https://a/x.mp4 again";
assert_eq!(extract_media_urls(t).len(), 1);
}
#[test]
fn rejects_non_media_and_bare_scheme() {
assert!(extract_media_urls("https://a/x.html").is_empty());
assert!(extract_media_urls("https://").is_empty());
assert!(extract_media_urls("no urls here").is_empty());
}
#[test]
fn is_media_url_handles_query_and_fragment() {
assert!(is_media_url("https://a/b/c.m3u8?x=1"));
assert!(is_media_url("https://a/b/c.MP4#t=3"));
assert!(!is_media_url("https://a/b/c.m3u8.html"));
assert!(!is_media_url("not a url"));
}
#[test]
fn service_page_encodes_the_target() {
let u = service_page(0, "https://www.bilibili.com/video/BV1xx?a=1");
assert!(u.starts_with(JX_BASE));
assert!(u.contains("%3A%2F%2F"), "冒号和斜杠都要编码:{u}");
assert!(u.contains("BV1xx"));
}
#[test]
fn empty_url_is_rejected_before_any_request() {
let (msg, _) = resolve_direct(0, " ").unwrap_err();
assert!(msg.contains("没填"));
}
#[test]
fn hellotik_says_it_is_a_web_tool() {
let (msg, _) = resolve_direct(1, "https://www.bilibili.com/video/BV1xx").unwrap_err();
assert!(msg.contains("网页工具"));
}
}