Skip to content
Clone
8923bc0
1009 lines 23.2 KB Rust
//! 平台解析:通用 [`Platform`] trait + 正则路由。
//!
//! 每个平台一个文件(`parse/<id>.rs`):文件里只声明元数据 + 一条声明式
//! [`EmbedRule`],声明式表达不了的平台可以覆写 [`Platform::parse`]。
//!
//! 路由对每个域名编译一条正则 `(^|\.)example\.com$`,域名越长越优先,
//! 命中的平台再套自己的规则拼出 iframe src。全程不联网。
use std::collections::HashMap;
use std::sync::{OnceLock, RwLock};
use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
use regex::Regex;
use url::Url;
use crate::{Aspect, Category, EmbedKind, Resolved};
/// 用于路径参数编码:保留 URL 路径/查询里安全且常见的 `-_.~`。
const COMPONENT: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'%')
.add(b'&')
.add(b'\'')
.add(b'(')
.add(b')')
.add(b'*')
.add(b'+')
.add(b',')
.add(b'/')
.add(b':')
.add(b';')
.add(b'<')
.add(b'=')
.add(b'>')
.add(b'?')
.add(b'@')
.add(b'[')
.add(b'\\')
.add(b']')
.add(b'^')
.add(b'`')
.add(b'{')
.add(b'|')
.add(b'}');
/// 一条声明式嵌入规则:从页面 URL 拼出 iframe `src`。
///
/// `pattern` 为 `None` 时直接用原 URL 拼模板;否则先把 `pattern` 应用到
/// `path?query#frag` 上抽捕获组,抽不到就退化成卡片。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EmbedRule {
/// 对 `path?query#frag` 匹配的正则。
pub pattern: Option<&'static str>,
/// `src` 模板:`{url}` / `{url_enc}` / `{path}` / `{host}` / `{query}` / `{frag}`,
/// 以及正则捕获组 `{id}`(等价 `{1}`)、`{2}`…`{9}`(自动 percent-encode)。
pub src: &'static str,
pub aspect: Aspect,
/// 平台自己声明的固定像素高。
///
/// 音乐播放器、推文卡片这类高宽比不是 16:9 的嵌入,用 `aspect` 会把它们
/// 撑成一块空框(Spotify 单曲实际只有 152px 高,网易云外链播放器只有 66px)。
/// 有值时优先于 `aspect`。
pub height: Option<u32>,
}
impl EmbedRule {
/// 不抽参数,直接用原 URL 拼模板。
pub const fn frame(src: &'static str) -> Self {
Self {
pattern: None,
src,
aspect: Aspect::Wide,
height: None,
}
}
/// 指定正则与宽高比。
pub const fn capture(pattern: &'static str, src: &'static str, aspect: Aspect) -> Self {
Self {
pattern: Some(pattern),
src,
aspect,
height: None,
}
}
/// 指定固定像素高(音乐播放器、社交卡片这类非 16:9 的嵌入)。
pub const fn fixed_height(mut self, height: u32) -> Self {
self.height = Some(height);
self
}
/// 16:9 的正则规则。
pub const fn wide(pattern: &'static str, src: &'static str) -> Self {
Self::capture(pattern, src, Aspect::Wide)
}
/// 1:1 的正则规则。
pub const fn square(pattern: &'static str, src: &'static str) -> Self {
Self::capture(pattern, src, Aspect::Square)
}
/// 9:16(竖屏短视频、直播)的正则规则。
pub const fn vertical(pattern: &'static str, src: &'static str) -> Self {
Self::capture(pattern, src, Aspect::Vertical)
}
/// 4:5(信息流图片)的正则规则。
pub const fn tall(pattern: &'static str, src: &'static str) -> Self {
Self::capture(pattern, src, Aspect::Tall)
}
}
/// 一个可解析的站点。
///
/// - 站点禁止内嵌:`embed_rule` 返回 `None`,渲染成带平台标识的卡片。
/// - 声明式规则够用:只实现 [`Platform::embed_rule`]。
/// - 规则表达不了(多域名不同形状、需要多段拼装等):覆写 [`Platform::parse`],
/// 用 [`capture_args`] + [`platform_resolved`] 组装结果。
pub trait Platform: Sync + 'static {
/// 稳定标识,会进 embed JSON。
fn id(&self) -> &'static str;
/// 展示名。
fn name(&self) -> &'static str;
fn category(&self) -> Category;
/// 域名后缀,命中规则见 [`route`]。
fn domains(&self) -> &'static [&'static str];
/// 声明式规则;`None` 表示只能出卡片。
fn embed_rule(&self) -> Option<EmbedRule> {
None
}
/// oEmbed 端点模板(`{url}` / `{url_enc}` 占位)。有值的平台,
/// chat 侧可以联网补全标题、封面和作者;补全结果里的 iframe 只有落在
/// 该平台自己声明的域名下才会被采用。
fn oembed(&self) -> Option<&'static str> {
None
}
/// 解析入口,默认实现走 [`default_parse`]。
fn parse(&self, url: &Url) -> Option<Resolved> {
default_parse(self, url)
}
}
struct Route {
host: Regex,
platform: &'static dyn Platform,
domain_len: usize,
}
fn routes() -> &'static [Route] {
static CACHE: OnceLock<Vec<Route>> = OnceLock::new();
CACHE.get_or_init(|| {
let mut routes = Vec::new();
for platform in PLATFORMS {
for domain in platform.domains() {
let pattern = format!(r"(^|\.){}$", regex::escape(domain));
let Ok(host) = Regex::new(&pattern) else {
continue;
};
routes.push(Route {
host,
platform: *platform,
domain_len: domain.len(),
});
}
}
// 域名越长越优先;稳定排序保证同长度时先注册的平台先命中。
routes.sort_by_key(|route| std::cmp::Reverse(route.domain_len));
routes
})
}
/// 按域名正则把 URL 路由到平台。
pub fn route(url: &Url) -> Option<&'static dyn Platform> {
let host = url.host_str()?.trim_end_matches('.').to_ascii_lowercase();
routes()
.iter()
.find(|route| route.host.is_match(&host))
.map(|route| route.platform)
}
/// 平台声明的 oEmbed 端点(占位符已按 URL 填好)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OembedEndpoint {
pub platform: &'static str,
pub platform_name: &'static str,
pub domains: &'static [&'static str],
pub endpoint: String,
}
/// URL 命中的平台若声明了 oEmbed 端点,返回可直接请求的地址。
pub fn oembed_endpoint(url: &Url) -> Option<OembedEndpoint> {
let platform = route(url)?;
let template = platform.oembed()?;
Some(OembedEndpoint {
platform: platform.id(),
platform_name: platform.name(),
domains: platform.domains(),
endpoint: substitute(template, url, &[]),
})
}
/// 全部平台,顺序与 `parse/` 目录一致。
pub fn providers() -> &'static [&'static dyn Platform] {
PLATFORMS
}
pub fn provider_count() -> usize {
PLATFORMS.len()
}
fn pattern_regex(pattern: &'static str) -> Option<Regex> {
static CACHE: OnceLock<RwLock<HashMap<&'static str, Regex>>> = OnceLock::new();
let cache = CACHE.get_or_init(|| RwLock::new(HashMap::new()));
if let Some(regex) = cache
.read()
.ok()
.and_then(|guard| guard.get(pattern).cloned())
{
return Some(regex);
}
let regex = Regex::new(pattern).ok()?;
if let Ok(mut guard) = cache.write() {
guard.insert(pattern, regex.clone());
}
Some(regex)
}
/// 按 `pattern` 从 `path?query#frag` 抽取捕获组。
pub fn capture_args(pattern: &'static str, url: &Url) -> Option<Vec<String>> {
let regex = pattern_regex(pattern)?;
let target = {
let mut target = url.path().to_string();
if let Some(query) = url.query() {
target.push('?');
target.push_str(query);
}
if let Some(fragment) = url.fragment() {
target.push('#');
target.push_str(fragment);
}
target
};
let captures = regex.captures(&target)?;
Some(
captures
.iter()
.skip(1)
.map(|group| {
group
.map(|hit| hit.as_str().to_string())
.unwrap_or_default()
})
.collect(),
)
}
fn encode_component(value: &str) -> String {
utf8_percent_encode(value, COMPONENT).to_string()
}
fn substitute(template: &str, url: &Url, caps: &[String]) -> String {
let mut out = String::with_capacity(template.len() + 32);
let mut rest = template;
while let Some(start) = rest.find('{') {
out.push_str(&rest[..start]);
let after = &rest[start + 1..];
let Some(end) = after.find('}') else {
out.push_str(&rest[start..]);
return out;
};
let key = &after[..end];
match key {
"url" => out.push_str(url.as_str()),
"url_enc" => out.push_str(&encode_component(url.as_str())),
"path" => out.push_str(url.path()),
"host" => out.push_str(url.host_str().unwrap_or_default()),
"query" => out.push_str(url.query().unwrap_or_default()),
"frag" => out.push_str(url.fragment().unwrap_or_default()),
other => {
let index = if other == "id" {
Some(1usize)
} else {
other.parse::<usize>().ok()
};
match index.and_then(|index| caps.get(index - 1)) {
Some(value) => out.push_str(&encode_component(value)),
None => out.push_str(&rest[start..start + end + 2]),
}
}
}
rest = &after[end + 1..];
}
out.push_str(rest);
out
}
/// 组装一个解析结果,供自定义 [`Platform::parse`] 使用。
pub fn platform_resolved<P: Platform + ?Sized>(
platform: &P,
url: &Url,
kind: EmbedKind,
src: Option<String>,
aspect: Aspect,
) -> Resolved {
platform_resolved_sized(platform, url, kind, src, aspect, None)
}
/// 同 [`platform_resolved`],但带平台声明的固定像素高。
pub fn platform_resolved_sized<P: Platform + ?Sized>(
platform: &P,
url: &Url,
kind: EmbedKind,
src: Option<String>,
aspect: Aspect,
height: Option<u32>,
) -> Resolved {
Resolved {
platform: platform.id(),
platform_name: platform.name(),
category: platform.category(),
kind,
url: url.to_string(),
src,
aspect,
height,
title: platform.name().to_string(),
description: None,
image_url: None,
}
}
/// 默认解析:按 [`Platform::embed_rule`] 拼 src,抽不到参数或没有规则就出卡片。
pub fn default_parse<P: Platform + ?Sized>(platform: &P, url: &Url) -> Option<Resolved> {
let Some(rule) = platform.embed_rule() else {
return Some(platform_resolved(
platform,
url,
EmbedKind::Link,
None,
Aspect::Auto,
));
};
match rule.pattern {
None => Some(platform_resolved_sized(
platform,
url,
EmbedKind::Iframe,
Some(substitute(rule.src, url, &[])),
rule.aspect,
rule.height,
)),
Some(pattern) => match capture_args(pattern, url) {
Some(caps) => Some(platform_resolved_sized(
platform,
url,
EmbedKind::Iframe,
Some(substitute(rule.src, url, &caps)),
rule.aspect,
rule.height,
)),
None => Some(platform_resolved(
platform,
url,
EmbedKind::Link,
None,
Aspect::Auto,
)),
},
}
}
macro_rules! platforms {
($($module:ident),* $(,)?) => {
$(
pub mod $module;
)*
/// 全部平台,顺序与 `parse/` 目录一致。
pub static PLATFORMS: &[&'static dyn Platform] = &[$(&$module::PLATFORM),*];
};
}
// --- 生成区域开始(每个平台一个模块,勿手改)---
platforms! {
wechat,
wechat_work,
wechat_channels,
dingtalk,
feishu,
douyin,
weibo,
xiaohongshu,
kuaishou,
momo,
tantan,
maimai,
jike,
zuiyou,
pipixia,
lofter,
jianshu,
renren,
kaixin,
qzone,
qq,
facebook,
instagram,
twitter,
tiktok,
threads,
bluesky,
mastodon,
linkedin,
telegram,
discord,
discord_invite,
snapchat,
pinterest,
tumblr,
vk,
line,
whatsapp,
viber,
kik,
pixiv,
weheartit,
askfm,
plurk,
badoo,
meetup,
nextdoor,
tinder,
bumble,
clubhouse,
gab,
truthsocial,
yikyak,
douban,
taobao,
tmall,
jd,
pinduoduo,
meituan,
eleme,
alipay,
xianyu,
zhuanzhuan,
suning,
vip,
dewu,
mogujie,
secoo,
dangdang,
gome,
jumei,
yhd,
mi_store,
vmall,
yanxuan,
p58,
ganji,
ctrip,
qunar,
fliggy,
kfc,
mcdonalds,
starbucks,
luckin,
amazon,
ebay,
etsy,
shopify,
walmart,
aliexpress,
p1688,
rakuten,
mercadolibre,
flipkart,
lazada,
shopee,
tokopedia,
coupang,
zalando,
asos,
target,
bestbuy,
homedepot,
costco,
ikea,
nike,
adidas,
sephora,
airbnb,
booking,
agoda,
expedia,
tripadvisor,
doordash,
ubereats,
grubhub,
instacart,
bilibili,
b23,
acfun,
iqiyi,
youku,
qqvideo,
mgtv,
sohutv,
letv,
tudou,
pptv,
migu_video,
xigua,
haokan,
miaopai,
meipai,
weishi,
p56,
p6rooms,
huoshan,
youtube,
youtu_short,
youtube_music,
vimeo,
dailymotion,
netflix,
hulu,
disneyplus,
max,
primevideo,
crunchyroll,
funimation,
vevo,
ted,
nicovideo,
peacock,
paramount,
appletv,
hotstar,
zee5,
viki,
wetv,
linetv,
kktv,
hanju,
odysee,
rumble,
bitchute,
veoh,
metacafe,
archive,
netease_music,
qqmusic,
kugou,
kuwo,
xiami,
migu_music,
qianqian,
ximalaya,
lizhi,
qingting,
dedao,
xiaoyuzhou,
p5sing,
quanmin,
changba,
spotify,
applemusic,
soundcloud,
bandcamp,
deezer,
tidal,
amazonmusic,
pandora,
iheartradio,
mixcloud,
audiomack,
napster,
qobuz,
joox,
kkbox,
line_music,
awa,
anghami,
boomplay,
audius,
podbean,
anchor,
baidu,
baijiahao,
sina_news,
netease_news,
qq_news,
sohu,
ifeng,
thepaper,
jiemian,
caixin,
guancha,
huanqiu,
people,
xinhua,
cctv,
chinanews,
toutiao,
yidian,
zaker,
p36kr,
huxiu,
tmtpost,
sspai,
ifanr,
iyiou,
xueqiu,
eastmoney,
cls,
bbc,
cnn,
nytimes,
washingtonpost,
theguardian,
reuters,
apnews,
bloomberg,
wsj,
forbes,
techcrunch,
theverge,
wired,
hackernews,
medium,
substack,
zhihu,
zhihu_column,
tieba,
reddit,
reddit_short,
quora,
stackoverflow,
v2ex,
hupu,
nga,
tianya,
mop,
newsmth,
lkong,
tiexue,
discuz,
phpbb,
discourse,
p4chan,
somethingawful,
resetera,
neogaf,
enshan,
p52pojie,
kafan,
gfan,
coolapk,
lobsters,
slashdot,
digg,
metafilter,
bilibili_live,
douyin_live,
kuaishou_live,
taobao_live,
douyu,
huya,
yy,
huajiao,
inke,
qie,
longzhu,
zhanqi,
panda,
netease_cc,
twitch,
kick,
trovo,
dlive,
mixer,
nimotv,
bigo,
p17live,
uplive,
langlive,
soop,
showroom,
mildom,
wikipedia,
baike,
wenku,
baidupan,
baidumap,
baidu_search,
douban_book,
douban_movie,
guokr,
zcool,
huaban,
notion,
yuque,
obsidian,
gitbook,
arxiv,
scholar,
doi,
pubmed,
nature,
science,
ieee,
springer,
sciencedirect,
wiley,
cell,
britannica,
github,
gitlab,
gitee,
bitbucket,
sourceforge,
codepen,
jsfiddle,
codesandbox,
stackblitz,
replit,
glitch,
vercel,
netlify,
cloudflare,
dockerhub,
figma,
canva,
dribbble,
behance,
unsplash,
pexels,
pixabay,
producthunt,
devto,
hashnode,
huggingface,
openai,
anthropic,
replicate,
kaggle,
colab,
googledrive,
miro,
excalidraw,
whimsical,
trello,
asana,
monday,
atlassian,
linear,
airtable,
zapier,
ifttt,
postman,
swagger,
sentry,
datadog,
grafana,
elastic,
mongodb,
redis,
postgresql,
sqlite,
rustlang,
docsrs,
cratesio,
npm,
pypi,
python,
nodejs,
typescriptlang,
react,
vuejs,
svelte,
nextjs,
nuxt,
tauri,
electron,
flutter,
kotlin,
golang,
swift,
llvm,
debian,
ubuntu,
archlinux,
kernel,
android,
apple_dev,
microsoft,
mdn,
readthedocs,
csdn,
cnblogs,
juejin,
segmentfault,
infoq,
oschina,
googlemaps,
}
// --- 生成区域结束 ---
#[cfg(test)]
mod tests {
use super::*;
fn url_of(raw: &str) -> Url {
Url::parse(raw).expect("test url")
}
#[test]
fn longest_domain_wins() {
let wechat = route(&url_of("https://mp.weixin.qq.com/s/AbCdEfGhIjKlMn")).unwrap();
assert_eq!(wechat.id(), "wechat");
let qq = route(&url_of("https://im.qq.com/index")).unwrap();
assert_eq!(qq.id(), "qq");
assert!(route(&url_of("https://example.com/")).is_none());
}
#[test]
fn declared_patterns_compile() {
for platform in providers() {
if let Some(rule) = platform.embed_rule()
&& let Some(pattern) = rule.pattern
{
assert!(
Regex::new(pattern).is_ok(),
"{} has an invalid pattern {pattern}",
platform.id()
);
}
}
}
#[test]
fn custom_parse_overrides_default() {
struct Custom;
impl Platform for Custom {
fn id(&self) -> &'static str {
"custom"
}
fn name(&self) -> &'static str {
"自定义"
}
fn category(&self) -> Category {
Category::Other
}
fn domains(&self) -> &'static [&'static str] {
&["custom.example"]
}
fn embed_rule(&self) -> Option<EmbedRule> {
None
}
fn parse(&self, url: &Url) -> Option<Resolved> {
let caps = capture_args(r"^/watch/([A-Za-z0-9]+)", url)?;
Some(platform_resolved(
self,
url,
EmbedKind::Iframe,
Some(format!("https://custom.example/embed/{}", caps[0])),
Aspect::Vertical,
))
}
}
let resolved = Custom
.parse(&url_of("https://custom.example/watch/abc123"))
.unwrap();
assert_eq!(resolved.kind, EmbedKind::Iframe);
assert_eq!(
resolved.src.as_deref(),
Some("https://custom.example/embed/abc123")
);
assert_eq!(resolved.aspect, Aspect::Vertical);
assert!(
Custom
.parse(&url_of("https://custom.example/other"))
.is_none()
);
}
#[test]
fn default_parse_degrades_to_card() {
struct Carded;
impl Platform for Carded {
fn id(&self) -> &'static str {
"carded"
}
fn name(&self) -> &'static str {
"只有卡片"
}
fn category(&self) -> Category {
Category::News
}
fn domains(&self) -> &'static [&'static str] {
&["carded.example"]
}
}
let resolved = Carded.parse(&url_of("https://carded.example/a/b")).unwrap();
assert_eq!(resolved.kind, EmbedKind::Link);
assert!(resolved.src.is_none());
assert_eq!(resolved.title, "只有卡片");
}
#[test]
fn provider_index_is_wellformed() {
assert!(
provider_count() >= 300,
"expected at least 300 platforms, got {}",
provider_count()
);
let mut ids: Vec<&str> = providers().iter().map(|platform| platform.id()).collect();
ids.sort_unstable();
let before = ids.len();
ids.dedup();
assert_eq!(before, ids.len(), "duplicate platform ids");
for platform in providers() {
assert!(
!platform.domains().is_empty(),
"{} has no domains",
platform.id()
);
for domain in platform.domains() {
assert!(
!domain.contains('/')
&& !domain.contains(':')
&& !domain.contains(char::is_whitespace)
&& domain.split('.').all(|label| !label.is_empty()),
"{} has a non-hostname domain {domain}",
platform.id()
);
}
}
}
/// oEmbed 端点必须是 https、带 `{url}`/`{url_enc}` 占位,否则 chat 侧抓不到东西。
#[test]
fn oembed_templates_are_complete() {
let mut declared = Vec::new();
for platform in providers() {
let Some(template) = platform.oembed() else {
continue;
};
declared.push(platform.id());
assert!(
template.starts_with("https://"),
"{} declares a non-https oembed endpoint {template}",
platform.id()
);
assert!(
template.contains("{url}") || template.contains("{url_enc}"),
"{} declares an oembed endpoint without a url placeholder",
platform.id()
);
}
assert!(
declared.len() >= 5,
"expected several oembed platforms, got {declared:?}"
);
}
/// oEmbed 接口必须挂在该平台自己声明的域名下(含子域),
/// 否则联网补全就成了替第三方站点发请求。
#[test]
fn oembed_endpoints_live_on_the_platform_own_domains() {
for platform in providers() {
let Some(template) = platform.oembed() else {
continue;
};
let host = template
.trim_start_matches("https://")
.split(['/', '?'])
.next()
.unwrap_or_default()
.to_ascii_lowercase();
assert!(!host.is_empty(), "{} declares an empty host", platform.id());
assert!(
platform
.domains()
.iter()
.any(|domain| host == *domain || host.ends_with(&format!(".{domain}"))),
"{} declares an oembed endpoint on a foreign host {host}",
platform.id()
);
}
}
}