跳转至

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("网页工具"));
    }
}