//! URL → 平台匹配与 embed 解析。 //! //! 平台定义与域名路由在 [`crate::parse`];这里只做入口校验、直接媒体兜底, //! 以及对外的 [`parse`] / [`embed_json`] 包装。 use url::Url; use crate::parse::OembedEndpoint; use crate::{Aspect, Category, Embed, EmbedKind, Enrichment, Resolved}; fn direct_media(url: &Url) -> Option<(&'static str, &'static str, EmbedKind)> { let path = url.path().to_ascii_lowercase(); let ext = path.rsplit('.').next().unwrap_or_default(); let kind = match ext { "mp4" | "webm" | "mov" | "m4v" | "ogv" => EmbedKind::Video, "mp3" | "m4a" | "aac" | "flac" | "wav" | "ogg" | "opus" => EmbedKind::Audio, "jpg" | "jpeg" | "png" | "gif" | "webp" | "avif" | "bmp" | "svg" => EmbedKind::Image, _ => return None, }; let (id, name) = match kind { EmbedKind::Image => ("web-image", "图片"), EmbedKind::Audio => ("web-audio", "音频"), _ => ("web-video", "视频"), }; Some((id, name, kind)) } /// 解析一个 URL。非 http(s)、无 host、或不在注册表里(且不是直接媒体链接)时返回 `None`。 pub fn parse(raw: &str) -> Option { let trimmed = raw.trim(); if trimmed.len() > 4096 { return None; } let url = Url::parse(trimmed).ok()?; if !matches!(url.scheme(), "http" | "https") { return None; } url.host_str()?; if let Some(platform) = crate::parse::route(&url) { return platform.parse(&url); } let (id, name, kind) = direct_media(&url)?; let aspect = match kind { EmbedKind::Video => Aspect::Wide, _ => Aspect::Auto, }; Some(Resolved { platform: id, platform_name: name, category: Category::Other, kind, url: url.to_string(), src: Some(url.to_string()), aspect, height: None, title: name.to_string(), description: None, image_url: None, }) } /// chat 消息 `embeds` 数组里的一项(`type = "platform"`)。 pub fn embed_json(raw: &str) -> Option { let resolved = parse(raw)?; let html = crate::render_html(&resolved); Some(Embed { resolved, html }.to_json()) } /// URL 命中的平台声明的 oEmbed 端点(未声明时返回 `None`)。 pub fn oembed_endpoint(raw: &str) -> Option { let url = Url::parse(raw.trim()).ok()?; if !matches!(url.scheme(), "http" | "https") { return None; } crate::parse::oembed_endpoint(&url) } fn absolute_http_url(value: &str) -> Option<&str> { let trimmed = value.trim(); if trimmed.is_empty() || trimmed.len() > 2048 { return None; } let url = Url::parse(trimmed).ok()?; matches!(url.scheme(), "http" | "https").then_some(trimmed) } /// oEmbed 返回的 iframe 地址只有在平台自己声明的域名下才采用, /// 否则第三方页面会被嵌进消息流。 fn owned_frame_src(raw: &str, url: &Url) -> Option { let candidate = absolute_http_url(raw)?; let host = Url::parse(candidate) .ok()? .host_str()? .trim_end_matches('.') .to_ascii_lowercase(); let platform = crate::parse::route(url)?; platform .domains() .iter() .any(|domain| { let domain = domain.trim_start_matches('.').to_ascii_lowercase(); host == domain || host.ends_with(&format!(".{domain}")) }) .then(|| candidate.to_string()) } /// 解析 + 合并联网补全(标题 / 简介 / 封面 / iframe)+ 渲染。 pub fn embed_json_enriched(raw: &str, enrichment: &Enrichment) -> Option { let trimmed = raw.trim(); let mut resolved = parse(trimmed)?; let url = Url::parse(trimmed).ok()?; { let resolved = &mut resolved; let url = &url; if let Some(title) = enrichment .title .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { resolved.title = title.chars().take(200).collect(); } if let Some(description) = enrichment .description .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { resolved.description = Some(description.chars().take(300).collect()); } if let Some(image) = enrichment.image_url.as_deref().and_then(absolute_http_url) { resolved.image_url = Some(image.to_string()); } if resolved.kind == EmbedKind::Link && let Some(frame) = enrichment .frame_src .as_deref() .and_then(|raw| owned_frame_src(raw, url)) { resolved.kind = EmbedKind::Iframe; resolved.src = Some(frame); resolved.aspect = Aspect::Wide; } } let html = crate::render_html(&resolved); Some(Embed { resolved, html }.to_json()) } #[cfg(test)] mod tests { use super::*; fn kind_of(url: &str) -> Option<(EmbedKind, Option, &'static str)> { parse(url).map(|resolved| (resolved.kind, resolved.src, resolved.platform)) } #[test] fn resolves_the_named_platforms() { let cases: &[(&str, &str)] = &[ ("https://mp.weixin.qq.com/s/AbCdEfGhIjKlMn", "wechat"), ("https://www.douyin.com/video/7412345678901234567", "douyin"), ("https://weibo.com/1234567890/AbCdEfGh", "weibo"), ("https://www.bilibili.com/video/BV1xx411c7mD", "bilibili"), ("https://www.zhihu.com/question/12345678", "zhihu"), ("https://item.taobao.com/item.htm?id=123456", "taobao"), ("https://item.jd.com/100012043978.html", "jd"), ("https://www.meituan.com/meishi/123456/", "meituan"), ("https://www.alipay.com/", "alipay"), ("https://www.dingtalk.com/", "dingtalk"), ("https://www.feishu.cn/", "feishu"), ("https://www.baidu.com/s?wd=gitdata", "baidu"), ("https://music.163.com/#/song?id=1234567", "netease-music"), ("https://www.iqiyi.com/v_1abcdefg.html", "iqiyi"), ("https://www.facebook.com/permalink.php", "facebook"), ("https://www.instagram.com/p/Cabcdefghij/", "instagram"), ("https://www.youtube.com/watch?v=dQw4w9WgXcQ", "youtube"), ( "https://www.tiktok.com/@user/video/7412345678901234567", "tiktok", ), ("https://x.com/user/status/1234567890123456789", "twitter"), ("https://www.netflix.com/title/80100172", "netflix"), ]; for (url, expected) in cases { let resolved = parse(url).unwrap_or_else(|| panic!("{url} did not resolve")); assert_eq!(resolved.platform, *expected, "{url}"); } } /// 本轮抽检修掉的回归点,逐条钉住,避免以后静默退化。 #[test] fn regression_cases_from_the_platform_sweep() { let src_of = |url: &str| { let resolved = parse(url).unwrap_or_else(|| panic!("{url} did not resolve")); ( resolved.platform, resolved.kind, resolved.src.unwrap_or_default(), ) }; // Twitch:频道直播此前只认 `/videos/`,频道页退化成卡片。 let (platform, kind, src) = src_of("https://www.twitch.tv/xqc"); assert_eq!(platform, "twitch"); assert_eq!(kind, EmbedKind::Iframe); assert!(src.contains("channel=xqc"), "{src}"); // 目录页不能被当成主播。 assert_eq!(src_of("https://www.twitch.tv/directory").1, EmbedKind::Link); // B 站:旧版 av 号与番剧 ep 号各自拼对播放器参数。 let (_, _, src) = src_of("https://www.bilibili.com/video/av1134025"); assert!(src.contains("aid=1134025"), "{src}"); let (_, _, src) = src_of("https://www.bilibili.com/bangumi/play/ep123456"); assert!(src.contains("ep_id=123456"), "{src}"); // YouTube 播放列表走 videoseries,而不是 embed/{id}。 let (_, kind, src) = src_of("https://www.youtube.com/playlist?list=PLMC9KNkIncKtPzgY"); assert_eq!(kind, EmbedKind::Iframe); assert!(src.contains("videoseries?list=PLMC9KNkIncKtPzgY"), "{src}"); // JSFiddle:`//` 不能把用户名当 id,路径要原样保留。 let (_, kind, src) = src_of("https://jsfiddle.net/zalun/Nmud8/"); assert_eq!(kind, EmbedKind::Iframe); assert_eq!( src, "https://jsfiddle.net/zalun/Nmud8/embedded/js,html,css,result/dark/" ); let (_, kind, src) = src_of("https://jsfiddle.net/6he52mnr/1/"); assert_eq!(kind, EmbedKind::Iframe); assert!( src.starts_with("https://jsfiddle.net/6he52mnr/1/embedded/"), "{src}" ); // QQ 音乐:歌曲页用官方外链播放器。 let (_, kind, src) = src_of("https://y.qq.com/n/ryqq/songDetail/0039MnYb0qxYhV"); assert_eq!(kind, EmbedKind::Iframe); assert!(src.contains("songid=0039MnYb0qxYhV"), "{src}"); // 歌单/歌手没有公开播放器,保持卡片。 assert_eq!( src_of("https://y.qq.com/n/ryqq/playlist/1234567890").1, EmbedKind::Link ); // 喜马拉雅:声音页与专辑页各用对应播放器。 let (_, kind, src) = src_of("https://www.ximalaya.com/sound/123456789"); assert_eq!(kind, EmbedKind::Iframe); assert!(src.contains("/sound/player.html?id=123456789"), "{src}"); let (_, _, src) = src_of("https://www.ximalaya.com/album/12345678"); assert!(src.contains("/album/player.html?id=12345678"), "{src}"); // Audius:`//` -> `/embed//`。 let (_, kind, src) = src_of("https://audius.co/deadmau5/strobe"); assert_eq!(kind, EmbedKind::Iframe); assert_eq!(src, "https://audius.co/embed/deadmau5/strobe"); // Google Maps:不能再把整个 URL 塞进 `q=`。 let (_, _, src) = src_of("https://maps.google.com/maps?q=tokyo"); assert_eq!(src, "https://maps.google.com/maps?q=tokyo&output=embed"); let (_, _, src) = src_of("https://www.google.com/maps/place/Tokyo"); assert_eq!(src, "https://maps.google.com/maps?q=Tokyo&output=embed"); // 非地图的 google.com 链接不能被认领。 assert!(parse("https://www.google.com/search?q=rust").is_none()); } #[test] fn builds_youtube_iframe_and_falls_back_to_card() { let (kind, src, _) = kind_of("https://youtu.be/dQw4w9WgXcQ").unwrap(); assert_eq!(kind, EmbedKind::Iframe); assert_eq!( src.as_deref(), Some("https://www.youtube.com/embed/dQw4w9WgXcQ") ); let (kind, src, _) = kind_of("https://www.youtube.com/results?search_query=rust").unwrap(); assert_eq!(kind, EmbedKind::Link); assert!(src.is_none()); } #[test] fn spotify_uses_two_captures() { let (kind, src, _) = kind_of("https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT").unwrap(); assert_eq!(kind, EmbedKind::Iframe); assert_eq!( src.as_deref(), Some("https://open.spotify.com/embed/track/4cOdK2wGLETKBW3PvgPWqT") ); } #[test] fn direct_media_is_embedded() { let (kind, src, platform) = kind_of("https://cdn.example.com/clip.mp4").unwrap(); assert_eq!(kind, EmbedKind::Video); assert_eq!(platform, "web-video"); assert_eq!(src.as_deref(), Some("https://cdn.example.com/clip.mp4")); } #[test] fn rejects_non_http() { assert!(parse("javascript:alert(1)").is_none()); assert!(parse("file:///etc/passwd").is_none()); assert!(parse("not a url").is_none()); } #[test] fn oembed_endpoint_follows_the_declared_platform() { let endpoint = oembed_endpoint("https://www.youtube.com/watch?v=dQw4w9WgXcQ").expect("youtube"); assert_eq!(endpoint.platform, "youtube"); assert!( endpoint .endpoint .starts_with("https://www.youtube.com/oembed") ); assert!(endpoint.endpoint.contains("dQw4w9WgXcQ")); assert!(!endpoint.endpoint.contains('{')); assert!(oembed_endpoint("https://example.com/post").is_none()); } #[test] fn enrichment_fills_the_card_but_never_foreign_frames() { let url = "https://mp.weixin.qq.com/s/AbCdEfGhIjKlMn"; let enrichment = Enrichment { title: Some(" 文章标题 ".into()), description: Some("摘要".into()), image_url: Some("https://mmbiz.qpic.cn/cover.jpg".into()), frame_src: Some("https://evil.example/frame".into()), }; let value = embed_json_enriched(url, &enrichment).unwrap(); assert_eq!(value["kind"], "link"); assert_eq!(value["title"], "文章标题"); assert_eq!(value["description"], "摘要"); assert_eq!(value["image_url"], "https://mmbiz.qpic.cn/cover.jpg"); let html = value["html"].as_str().unwrap(); assert!(html.contains("gd-embed-thumb"), "{html}"); assert!(html.contains("摘要"), "{html}"); assert!(!html.contains("evil.example"), "{html}"); } #[test] fn owned_frames_upgrade_a_card_and_unsafe_urls_are_dropped() { let url = "https://mp.weixin.qq.com/s/AbCdEfGhIjKlMn"; let enrichment = Enrichment { frame_src: Some("https://mp.weixin.qq.com/mp/embed?x=1".into()), ..Enrichment::default() }; let value = embed_json_enriched(url, &enrichment).unwrap(); assert_eq!(value["kind"], "iframe"); assert_eq!(value["src"], "https://mp.weixin.qq.com/mp/embed?x=1"); assert!(value["html"].as_str().unwrap().contains("