fix(social): correct eight providers found in the sweep
Two new binaries keep this honest: examples/audit.rs reports domain
ownership collisions and route loops across the registry, and
examples/probe.rs reads URLs on stdin and prints platform/kind/src for a
live spot check. The regression test in resolve.rs pins every case below.
- twitch: a channel page is now a live-player iframe (was: only
/videos/<id> matched, so a streamer link degraded to a card), while
/directory stays a card.
- bilibili: legacy av ids map to aid=, bangumi ep ids to ep_id=.
- youtube: playlists use /embed/videoseries?list=, not embed/{id}.
- jsfiddle: /<user>/<id> keeps the whole path instead of reading the
username as the id, and numbered revisions resolve.
- qqmusic: songDetail gets the official mini player; playlists and
artist pages have no public player and remain cards.
- ximalaya: sound and album pages use their own player URLs.
- audius / googlemaps: src and matching tightened.
ZhenYi committed
Added lib/social/examples/audit.rs lib/social/examples/audit.rs +92 −0 View lib/social/examples/audit.rs
@@ -0,0 +1,92 @@
1//! 注册表结构审计:域名归属、路由回环、可疑条目。
2
3use std::collections::HashMap;
4
5fn main() {
6 let mut owners: HashMap<&str, Vec<&str>> = HashMap::new();
7 for platform in social::providers() {
8 for domain in platform.domains() {
9 owners.entry(domain).or_default().push(platform.id());
10 }
11 }
12
13 println!("== 一个域名被多个平台声明(后者永远命中不到)==");
14 let mut clashes: Vec<_> = owners.iter().filter(|(_, ids)| ids.len() > 1).collect();
15 clashes.sort();
16 for (domain, ids) in &clashes {
17 println!(" {domain}: {ids:?}");
18 }
19 if clashes.is_empty() {
20 println!(" (无)");
21 }
22
23 println!("\n== 路由回环失败(声明了这个域名但路由不到自己)==");
24 // 路径级平台:声明的是「域名 + 特定路径」,根路径本来就该让给别人。
25 // googlemaps 声明 google.com 是为了认领 `/maps/...`,搜索/邮箱等不是它的。
26 const PATH_SCOPED: &[(&str, &str)] = &[("googlemaps", "google.com")];
27 let mut broken = 0;
28 for platform in social::providers() {
29 for domain in platform.domains() {
30 let probe = format!("https://www.{domain}/");
31 let routed = social::parse(&probe).map(|r| r.platform);
32 if routed != Some(platform.id()) {
33 if PATH_SCOPED
34 .iter()
35 .any(|(id, scoped)| *id == platform.id() && *scoped == *domain)
36 {
37 println!(
38 " {} 声明 {domain}(路径级,根路径不认领,符合预期)",
39 platform.id()
40 );
41 continue;
42 }
43 println!(
44 " {} 声明 {domain},但 https://www.{domain}/ 路由到 {:?}",
45 platform.id(),
46 routed
47 );
48 broken += 1;
49 }
50 }
51 }
52 if broken == 0 {
53 println!(" (无)");
54 }
55
56 println!("\n== 可疑域名(标签数 < 2,会吞掉整个 TLD)==");
57 let mut suspicious = 0;
58 for platform in social::providers() {
59 for domain in platform.domains() {
60 if domain.split('.').count() < 2 {
61 println!(" {}: {domain}", platform.id());
62 suspicious += 1;
63 }
64 }
65 }
66 if suspicious == 0 {
67 println!(" (无)");
68 }
69
70 println!("\n== 注册表规模 ==");
71 let mut by_category: HashMap<&str, usize> = HashMap::new();
72 for platform in social::providers() {
73 *by_category.entry(platform.category().as_str()).or_default() += 1;
74 }
75 let mut rows: Vec<_> = by_category.into_iter().collect();
76 rows.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
77 for (category, count) in rows {
78 println!(" {category}: {count}");
79 }
80 println!(" 合计: {}", social::provider_count());
81 println!(" 域名条目: {}", owners.len());
82
83 println!("\n== 声明了 oEmbed 端点的平台 ==");
84 let mut declared = 0;
85 for platform in social::providers() {
86 if let Some(template) = platform.oembed() {
87 println!(" {} -> {template}", platform.id());
88 declared += 1;
89 }
90 }
91 println!(" 合计: {declared} / {}", social::provider_count());
92}
Added lib/social/examples/probe.rs lib/social/examples/probe.rs +25 −0 View lib/social/examples/probe.rs
@@ -0,0 +1,25 @@
1//! 逐条读 stdin 上的 URL,打出解析到的平台与 embed src,供联网抽检。
2//!
3//! 输出制表符分隔:`<input url> <platform> <kind> <src>`。
4//! 解析不出来时 platform 为 `-`,kind 为 `none`。
5
6use std::io::BufRead;
7
8fn main() {
9 for line in std::io::stdin().lock().lines() {
10 let Ok(line) = line else { continue };
11 let url = line.trim();
12 if url.is_empty() {
13 continue;
14 }
15 match social::parse(url) {
16 Some(resolved) => println!(
17 "{url}\t{}\t{}\t{}",
18 resolved.platform,
19 resolved.kind.as_str(),
20 resolved.src.as_deref().unwrap_or("-"),
21 ),
22 None => println!("{url}\t-\tnone\t-"),
23 }
24 }
25}
Modified lib/social/src/parse/audius.rs lib/social/src/parse/audius.rs +6 −1 View lib/social/src/parse/audius.rs
@@ -27,7 +27,12 @@
2727 }
2828
2929 fn embed_rule(&self) -> Option<EmbedRule> {
30 None
30 // `/[artist]/[track]` -> `/embed/[artist]/[track]`,实测返回播放器。
31 Some(EmbedRule::capture(
32 r"^/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)/?$",
33 "https://audius.co/embed/{1}/{2}",
34 crate::Aspect::Auto,
35 ))
3136 }
3237 fn oembed(&self) -> Option<&'static str> {
3338 Some("https://audius.co/oembed?url={url_enc}&format=json")
Modified lib/social/src/parse/bilibili.rs lib/social/src/parse/bilibili.rs +37 −1 View lib/social/src/parse/bilibili.rs
@@ -26,7 +26,43 @@
2626 }
2727
2828 fn embed_rule(&self) -> Option<EmbedRule> {
29 Some(EmbedRule::wide("^/video/(BV[A-Za-z0-9]+)", "https://player.bilibili.com/player.html?bvid={id}&autoplay=0"))
29 Some(EmbedRule::wide(
30 r"^/video/(BV[A-Za-z0-9]+)",
31 "https://player.bilibili.com/player.html?bvid={id}&autoplay=0",
32 ))
33 }
34
35 /// 旧版 `av` 号与番剧 `ep` 号走各自的播放器参数,声明式模板表达不了。
36 fn parse(&self, url: &url::Url) -> Option<crate::Resolved> {
37 use crate::parse::{capture_args, default_parse, platform_resolved};
38 use crate::EmbedKind;
39
40 if let Some(caps) = capture_args(r"^/video/av(\d+)", url) {
41 return Some(platform_resolved(
42 self,
43 url,
44 EmbedKind::Iframe,
45 Some(format!(
46 "https://player.bilibili.com/player.html?aid={}&autoplay=0",
47 caps[0]
48 )),
49 crate::Aspect::Wide,
50 ));
51 }
52 if let Some(caps) = capture_args(r"^/bangumi/play/(?:ep|ss)(\d+)", url) {
53 let param = if url.path().contains("/ep") { "ep_id" } else { "season_id" };
54 return Some(platform_resolved(
55 self,
56 url,
57 EmbedKind::Iframe,
58 Some(format!(
59 "https://player.bilibili.com/player.html?{param}={}&autoplay=0",
60 caps[0]
61 )),
62 crate::Aspect::Wide,
63 ));
64 }
65 default_parse(self, url)
3066 }
3167}
3268
Modified lib/social/src/parse/googlemaps.rs lib/social/src/parse/googlemaps.rs +58 −3 View lib/social/src/parse/googlemaps.rs
@@ -1,4 +1,4 @@
1//! Google Maps — 开发 — maps.google.com
1//! Google Maps — 开发 — maps.google.com, google.com, maps.app.goo.gl
22//!
33//! googlemaps
44
@@ -22,11 +22,66 @@
2222 }
2323
2424 fn domains(&self) -> &'static [&'static str] {
25 &["maps.google.com"]
25 &["maps.google.com", "google.com", "maps.app.goo.gl"]
2626 }
2727
2828 fn embed_rule(&self) -> Option<EmbedRule> {
29 Some(EmbedRule::frame("https://maps.google.com/maps?q={url_enc}&output=embed"))
29 // 原实现把整个 URL 塞进 `q=`,生成 `?q=https%3A%2F%2Fmaps.google.com…`
30 // 这种自我嵌套的地址。这里只取原 URL 的 `q` 参数,其余形态交给 `parse`。
31 Some(EmbedRule::capture(
32 r"[?&]q=([^&]+)",
33 "https://maps.google.com/maps?q={id}&output=embed",
34 crate::Aspect::Wide,
35 ))
36 }
37
38 /// `/maps/place/<名字>` 与 `/maps/@lat,lng,zoom` 各自取对参数;
39 /// `google.com` 只在 `/maps` 路径上认领,否则搜索/邮箱/云盘会被误判成地图。
40 fn parse(&self, url: &url::Url) -> Option<crate::Resolved> {
41 use crate::parse::{capture_args, default_parse, platform_resolved};
42 use crate::EmbedKind;
43
44 let host = url.host_str().unwrap_or_default();
45 let host_owned = host.trim_start_matches("www.");
46 // `maps.app.goo.gl` 是短链:落地地址由 chat 的重定向跟随补上,
47 // 这里先按卡片出,避免把短链本身当坐标。
48 let on_map_host = host_owned.starts_with("maps.google.");
49 if host_owned.starts_with("maps.app.goo.gl") {
50 return Some(platform_resolved(
51 self,
52 url,
53 EmbedKind::Link,
54 None,
55 crate::Aspect::Auto,
56 ));
57 }
58 if !on_map_host && !url.path().starts_with("/maps") {
59 // 非地图路径:让这条链接走普通卡片,别贴 Google Maps 的标签。
60 return None;
61 }
62 if let Some(caps) = capture_args(r"^/maps/place/([^/@]+)", url) {
63 let place: String = url::form_urlencoded::byte_serialize(caps[0].as_bytes()).collect();
64 return Some(platform_resolved(
65 self,
66 url,
67 EmbedKind::Iframe,
68 Some(format!("https://maps.google.com/maps?q={place}&output=embed")),
69 crate::Aspect::Wide,
70 ));
71 }
72 if let Some(caps) = capture_args(r"^/maps/@(-?\d+\.\d+),(-?\d+\.\d+)", url) {
73 return Some(platform_resolved(
74 self,
75 url,
76 EmbedKind::Iframe,
77 Some(format!(
78 "https://maps.google.com/maps?q={},{}&output=embed",
79 caps[0], caps[1]
80 )),
81 crate::Aspect::Wide,
82 ));
83 }
84 default_parse(self, url)
3085 }
3186}
3287
Modified lib/social/src/parse/jsfiddle.rs lib/social/src/parse/jsfiddle.rs +41 −1 View lib/social/src/parse/jsfiddle.rs
@@ -26,7 +26,47 @@
2626 }
2727
2828 fn embed_rule(&self) -> Option<EmbedRule> {
29 Some(EmbedRule::wide("^/([A-Za-z0-9]+)", "https://jsfiddle.net/{id}/embedded/js,html,css,result/dark/"))
29 None
30 }
31
32 /// fiddle 的嵌入地址是「原路径 + `/embedded/<tabs>/`」,路径本身要原样保留。
33 ///
34 /// 不能用 `{id}` 模板:`/zalun/Nmud8/`(用户名 + id)与 `/6he52mnr/1/`
35 /// (id + 版本)段数相同、无法靠正则区分谁是谁,而且 `{id}` 会把 `/` 转义成
36 /// `%2F`。官方文档给的写法就是整段路径后面接 `/embedded/…`。
37 fn parse(&self, url: &url::Url) -> Option<crate::Resolved> {
38 use crate::parse::platform_resolved;
39 use crate::EmbedKind;
40
41 let path = url.path().trim_end_matches('/');
42 let segments: Vec<&str> = path.trim_start_matches('/').split('/').collect();
43 // 只认 fiddle 路径:`/<id>` 或 `/<user>/<id>[/<version>]`。
44 // `/user/…`、`/settings` 这类是站点页面,不该被塞进 iframe。
45 const RESERVED: &[&str] = &["user", "settings", "pro", "about", "docs"];
46 if segments.is_empty()
47 || segments.len() > 3
48 || segments.iter().any(|segment| segment.is_empty())
49 || segments
50 .first()
51 .is_some_and(|head| RESERVED.contains(&head.to_ascii_lowercase().as_str()))
52 {
53 return Some(platform_resolved(
54 self,
55 url,
56 EmbedKind::Link,
57 None,
58 crate::Aspect::Auto,
59 ));
60 }
61 Some(platform_resolved(
62 self,
63 url,
64 EmbedKind::Iframe,
65 Some(format!(
66 "https://jsfiddle.net{path}/embedded/js,html,css,result/dark/"
67 )),
68 crate::Aspect::Wide,
69 ))
3070 }
3171}
3272
Modified lib/social/src/parse/qqmusic.rs lib/social/src/parse/qqmusic.rs +26 −1 View lib/social/src/parse/qqmusic.rs
@@ -1,7 +1,9 @@
11//! QQ音乐 — 音乐 — y.qq.com, c.y.qq.com, i.y.qq.com
22//!
33//! qqmusic
4//! 站点不允许内嵌,渲染成带平台标识的卡片。
4//!
5//! 歌曲页有官方外链播放器(`i.y.qq.com/n2/m/outchain/player`),实测可嵌;
6//! 其余页面(歌单、歌手、MV)没有公开播放器,仍退化成卡片。
57
68use crate::parse::{EmbedRule, Platform};
79use crate::Category;
@@ -29,6 +31,29 @@
2931 fn embed_rule(&self) -> Option<EmbedRule> {
3032 None
3133 }
34
35 /// 歌曲页取 `songDetail/<mid>` 或 `songmid`,拼官方外链播放器。
36 fn parse(&self, url: &url::Url) -> Option<crate::Resolved> {
37 use crate::parse::{capture_args, default_parse, platform_resolved};
38 use crate::EmbedKind;
39
40 let mid = capture_args(r"^/n/ryqq/songDetail/([A-Za-z0-9]+)", url)
41 .or_else(|| capture_args(r"[?&]songmid=([A-Za-z0-9]+)", url))
42 .or_else(|| capture_args(r"^/song/([A-Za-z0-9]+)", url));
43 if let Some(caps) = mid {
44 return Some(platform_resolved(
45 self,
46 url,
47 EmbedKind::Iframe,
48 Some(format!(
49 "https://i.y.qq.com/n2/m/outchain/player/index.html?songid={}&type=0",
50 caps[0]
51 )),
52 crate::Aspect::Auto,
53 ));
54 }
55 default_parse(self, url)
56 }
3257}
3358
3459pub static PLATFORM: Qqmusic = Qqmusic;
Modified lib/social/src/parse/twitch.rs lib/social/src/parse/twitch.rs +67 −2 View lib/social/src/parse/twitch.rs
@@ -22,11 +22,76 @@
2222 }
2323
2424 fn domains(&self) -> &'static [&'static str] {
25 &["twitch.tv"]
25 &["twitch.tv", "clips.twitch.tv"]
2626 }
2727
2828 fn embed_rule(&self) -> Option<EmbedRule> {
29 Some(EmbedRule::wide(r"^/videos/(\d+)", "https://player.twitch.tv/?video={id}&parent=gitdata.ai&parent=localhost&autoplay=false"))
29 // 只保留点播:频道与切片在 `parse` 里单独处理,因为三种形态的
30 // iframe 参数名不同(video / channel / clip),声明式模板表达不了。
31 Some(EmbedRule::wide(
32 r"^/videos/(\d+)",
33 "https://player.twitch.tv/?video={id}&parent=gitdata.ai&parent=localhost&autoplay=false",
34 ))
35 }
36
37 /// 频道直播与切片:`twitch.tv/<channel>`、`clips.twitch.tv/<slug>`。
38 fn parse(&self, url: &url::Url) -> Option<crate::Resolved> {
39 use crate::parse::{capture_args, default_parse, platform_resolved};
40 use crate::EmbedKind;
41
42 let parent = "parent=gitdata.ai&parent=localhost&autoplay=false";
43 // 切片站点是一级路径:clips.twitch.tv/<slug>。
44 if url
45 .host_str()
46 .is_some_and(|host| host.starts_with("clips."))
47 {
48 let slug = url.path().trim_start_matches('/').trim_end_matches('/');
49 if !slug.is_empty() {
50 let clip: String =
51 url::form_urlencoded::byte_serialize(slug.as_bytes()).collect();
52 return Some(platform_resolved(
53 self,
54 url,
55 EmbedKind::Iframe,
56 Some(format!("https://clips.twitch.tv/embed?clip={clip}&{parent}")),
57 crate::Aspect::Wide,
58 ));
59 }
60 }
61 if url.path().starts_with("/videos/")
62 && let Some(caps) = capture_args(r"^/videos/(\d+)", url)
63 {
64 return Some(platform_resolved(
65 self,
66 url,
67 EmbedKind::Iframe,
68 Some(format!("https://player.twitch.tv/?video={}&{parent}", caps[0])),
69 crate::Aspect::Wide,
70 ));
71 }
72 // 频道名:排除掉目录类路径,否则 `/directory` 会被当成主播。
73 const RESERVED: &[&str] = &[
74 "directory", "downloads", "jobs", "p", "settings", "subscriptions", "turbo", "videos",
75 "wallet", "friends", "inventory", "drops", "search", "store",
76 ];
77 if let Some(caps) = capture_args(r"^/([A-Za-z0-9_]{3,25})/?$", url) {
78 let channel = caps[0].to_ascii_lowercase();
79 if !RESERVED.contains(&channel.as_str()) {
80 return Some(platform_resolved(
81 self,
82 url,
83 EmbedKind::Iframe,
84 Some(format!(
85 "https://player.twitch.tv/?channel={}&{parent}",
86 url::form_urlencoded::byte_serialize(caps[0].as_bytes())
87 .collect::<String>()
88 )),
89 crate::Aspect::Wide,
90 ));
91 }
92 }
93 // 其余路径(目录、设置页等)保持卡片。
94 default_parse(self, url)
3095 }
3196}
3297
Modified lib/social/src/parse/ximalaya.rs lib/social/src/parse/ximalaya.rs +30 −1 View lib/social/src/parse/ximalaya.rs
@@ -1,7 +1,9 @@
11//! 喜马拉雅 — 音乐 — ximalaya.com, xima.tv
22//!
33//! ximalaya
4//! 站点不允许内嵌,渲染成带平台标识的卡片。
4//!
5//! 官方外链播放器(`thirdparty/player/sound/player.html`)实测可嵌;
6//! 声音页用它,专辑页用 album 版。
57
68use crate::parse::{EmbedRule, Platform};
79use crate::Category;
@@ -29,6 +31,33 @@
2931 fn embed_rule(&self) -> Option<EmbedRule> {
3032 None
3133 }
34
35 /// 声音页:`/sound/<id>`。专辑页:`/album/<id>`。
36 fn parse(&self, url: &url::Url) -> Option<crate::Resolved> {
37 use crate::parse::{capture_args, default_parse, platform_resolved};
38 use crate::EmbedKind;
39
40 let kind = if capture_args(r"^/album/(\d+)", url).is_some() {
41 "album"
42 } else {
43 "sound"
44 };
45 let id = capture_args(r"^/(?:sound|album|track)/(\d+)", url)
46 .or_else(|| capture_args(r"[?&]id=(\d+)", url));
47 if let Some(caps) = id {
48 return Some(platform_resolved(
49 self,
50 url,
51 EmbedKind::Iframe,
52 Some(format!(
53 "https://www.ximalaya.com/thirdparty/player/{kind}/player.html?id={}&type=red",
54 caps[0]
55 )),
56 crate::Aspect::Auto,
57 ));
58 }
59 default_parse(self, url)
60 }
3261}
3362
3463pub static PLATFORM: Ximalaya = Ximalaya;
Modified lib/social/src/parse/youtube.rs lib/social/src/parse/youtube.rs +24 −0 View lib/social/src/parse/youtube.rs
@@ -28,6 +28,30 @@
2828 fn embed_rule(&self) -> Option<EmbedRule> {
2929 Some(EmbedRule::wide(r"^/(?:watch\?v=|embed/|shorts/|live/|v/)([A-Za-z0-9_-]{6,})", "https://www.youtube.com/embed/{id}"))
3030 }
31
32 /// 播放列表与频道页走 `videoseries`,不是单个视频的 `embed/{id}`。
33 fn parse(&self, url: &url::Url) -> Option<crate::Resolved> {
34 use crate::parse::{capture_args, default_parse, platform_resolved};
35 use crate::EmbedKind;
36
37 // `watch?v=…&list=…` 有具体视频时优先放视频,纯 `playlist?list=` 才放列表。
38 let only_list = capture_args(r"^/playlist.*[?&]list=([A-Za-z0-9_-]+)", url);
39 if let Some(caps) = only_list {
40 return Some(platform_resolved(
41 self,
42 url,
43 EmbedKind::Iframe,
44 Some(format!(
45 "https://www.youtube.com/embed/videoseries?list={}",
46 caps[0]
47 )),
48 crate::Aspect::Wide,
49 ));
50 }
51 // 频道页不做内嵌:`listType=user_uploads` 已在 YouTube 侧停用
52 // (实测返回 152-4),硬塞 iframe 只会给用户一个错误框,不如留卡片。
53 default_parse(self, url)
54 }
3155 fn oembed(&self) -> Option<&'static str> {
3256 Some("https://www.youtube.com/oembed?url={url_enc}&format=json")
3357 }
Modified lib/social/src/resolve.rs lib/social/src/resolve.rs +76 −0 View lib/social/src/resolve.rs
@@ -198,6 +198,82 @@
198198 }
199199 }
200200
201 /// 本轮抽检修掉的回归点,逐条钉住,避免以后静默退化。
202 #[test]
203 fn regression_cases_from_the_platform_sweep() {
204 let src_of = |url: &str| {
205 let resolved = parse(url).unwrap_or_else(|| panic!("{url} did not resolve"));
206 (
207 resolved.platform,
208 resolved.kind,
209 resolved.src.unwrap_or_default(),
210 )
211 };
212
213 // Twitch:频道直播此前只认 `/videos/<id>`,频道页退化成卡片。
214 let (platform, kind, src) = src_of("https://www.twitch.tv/xqc");
215 assert_eq!(platform, "twitch");
216 assert_eq!(kind, EmbedKind::Iframe);
217 assert!(src.contains("channel=xqc"), "{src}");
218 // 目录页不能被当成主播。
219 assert_eq!(src_of("https://www.twitch.tv/directory").1, EmbedKind::Link);
220
221 // B 站:旧版 av 号与番剧 ep 号各自拼对播放器参数。
222 let (_, _, src) = src_of("https://www.bilibili.com/video/av1134025");
223 assert!(src.contains("aid=1134025"), "{src}");
224 let (_, _, src) = src_of("https://www.bilibili.com/bangumi/play/ep123456");
225 assert!(src.contains("ep_id=123456"), "{src}");
226
227 // YouTube 播放列表走 videoseries,而不是 embed/{id}。
228 let (_, kind, src) = src_of("https://www.youtube.com/playlist?list=PLMC9KNkIncKtPzgY");
229 assert_eq!(kind, EmbedKind::Iframe);
230 assert!(src.contains("videoseries?list=PLMC9KNkIncKtPzgY"), "{src}");
231
232 // JSFiddle:`/<user>/<id>` 不能把用户名当 id,路径要原样保留。
233 let (_, kind, src) = src_of("https://jsfiddle.net/zalun/Nmud8/");
234 assert_eq!(kind, EmbedKind::Iframe);
235 assert_eq!(
236 src,
237 "https://jsfiddle.net/zalun/Nmud8/embedded/js,html,css,result/dark/"
238 );
239 let (_, kind, src) = src_of("https://jsfiddle.net/6he52mnr/1/");
240 assert_eq!(kind, EmbedKind::Iframe);
241 assert!(
242 src.starts_with("https://jsfiddle.net/6he52mnr/1/embedded/"),
243 "{src}"
244 );
245
246 // QQ 音乐:歌曲页用官方外链播放器。
247 let (_, kind, src) = src_of("https://y.qq.com/n/ryqq/songDetail/0039MnYb0qxYhV");
248 assert_eq!(kind, EmbedKind::Iframe);
249 assert!(src.contains("songid=0039MnYb0qxYhV"), "{src}");
250 // 歌单/歌手没有公开播放器,保持卡片。
251 assert_eq!(
252 src_of("https://y.qq.com/n/ryqq/playlist/1234567890").1,
253 EmbedKind::Link
254 );
255
256 // 喜马拉雅:声音页与专辑页各用对应播放器。
257 let (_, kind, src) = src_of("https://www.ximalaya.com/sound/123456789");
258 assert_eq!(kind, EmbedKind::Iframe);
259 assert!(src.contains("/sound/player.html?id=123456789"), "{src}");
260 let (_, _, src) = src_of("https://www.ximalaya.com/album/12345678");
261 assert!(src.contains("/album/player.html?id=12345678"), "{src}");
262
263 // Audius:`/<artist>/<track>` -> `/embed/<artist>/<track>`。
264 let (_, kind, src) = src_of("https://audius.co/deadmau5/strobe");
265 assert_eq!(kind, EmbedKind::Iframe);
266 assert_eq!(src, "https://audius.co/embed/deadmau5/strobe");
267
268 // Google Maps:不能再把整个 URL 塞进 `q=`。
269 let (_, _, src) = src_of("https://maps.google.com/maps?q=tokyo");
270 assert_eq!(src, "https://maps.google.com/maps?q=tokyo&output=embed");
271 let (_, _, src) = src_of("https://www.google.com/maps/place/Tokyo");
272 assert_eq!(src, "https://maps.google.com/maps?q=Tokyo&output=embed");
273 // 非地图的 google.com 链接不能被认领。
274 assert!(parse("https://www.google.com/search?q=rust").is_none());
275 }
276
201277 #[test]
202278 fn builds_youtube_iframe_and_falls_back_to_card() {
203279 let (kind, src, _) = kind_of("https://youtu.be/dQw4w9WgXcQ").unwrap();