Skip to content
Clone
b6f7f73
203 lines 6.5 KB Rust
//! URL → 平台 embed 解析。
//!
//! `social` 把一条消息里的链接识别成具体平台(社交、电商、视频、音乐、新闻、
//! 论坛、直播、知识、开发工具等数百个站点),并生成可直接注入到消息流里的
//! embed HTML:站点允许内嵌的用 `<iframe>` / `<video>` / `<audio>` / `<img>`,
//! 不允许内嵌(`X-Frame-Options` / CSP 拦截)的退化成带平台标识的卡片。
//!
//! 注册表是纯静态数据([`parse::PLATFORMS`],每个平台一个文件),匹配与渲染都不做
//! 网络请求,因此可以在消息写入路径上同步调用。
pub mod error;
pub mod html;
pub mod http;
pub mod media;
pub mod net;
pub mod normalize;
pub mod oembed;
pub mod parse;
pub mod render;
pub mod resolve;
pub mod resolver;
pub mod resolvers;
pub mod types;
use serde::{Deserialize, Serialize};
pub use error::UnfurlError;
pub use parse::{EmbedRule, Platform, provider_count, providers};
pub use render::{escape_html, render_html};
pub use resolve::{embed_json, embed_json_enriched, oembed_endpoint, parse};
pub use resolver::{ResolveContext, Resolver, UnfurlEngine, UnfurlOptions, unfurl};
pub use types::{EmbedAuthor, EmbedField, EmbedFooter, EmbedMedia, EmbedProvider, MessageEmbed};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Category {
/// 社交:微博、X、Facebook、小红书……
Social,
/// 电商:淘宝、京东、Amazon、Shopify……
Ecommerce,
/// 视频:B 站、YouTube、爱奇艺、Netflix……
Video,
/// 音乐/音频:网易云、Spotify、Apple Music……
Music,
/// 新闻资讯:新华网、BBC、NYTimes、36 氪……
News,
/// 论坛社区:知乎、Reddit、V2EX、NGA……
Forum,
/// 直播:斗鱼、虎牙、Twitch、Kick……
Live,
/// 知识/文档:维基百科、豆瓣读书、Notion、语雀……
Knowledge,
/// 开发工具:GitHub、GitLab、CodePen、Figma……
Dev,
/// 其它站点(含直接媒体链接兜底)。
Other,
}
impl Category {
pub const fn as_str(self) -> &'static str {
match self {
Self::Social => "social",
Self::Ecommerce => "ecommerce",
Self::Video => "video",
Self::Music => "music",
Self::News => "news",
Self::Forum => "forum",
Self::Live => "live",
Self::Knowledge => "knowledge",
Self::Dev => "dev",
Self::Other => "other",
}
}
}
/// embed 的呈现形态,前端据此决定容器样式。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum EmbedKind {
/// 第三方播放器/内容页 iframe。
Iframe,
/// 直接视频文件,渲染 `<video>`。
Video,
/// 直接音频文件,渲染 `<audio>`。
Audio,
/// 直接图片文件,渲染 `<img>`。
Image,
/// 站点禁止内嵌,渲染带平台标识的卡片。
Link,
}
impl EmbedKind {
pub const fn as_str(self) -> &'static str {
match self {
Self::Iframe => "iframe",
Self::Video => "video",
Self::Audio => "audio",
Self::Image => "image",
Self::Link => "link",
}
}
}
/// iframe / 播放器的宽高比。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Aspect {
/// 16:9
Wide,
/// 1:1
Square,
/// 9:16(竖屏短视频、直播)
Vertical,
/// 4:5(信息流图片)
Tall,
/// 由内容决定
Auto,
}
impl Aspect {
pub const fn css(self) -> &'static str {
match self {
Self::Wide => "16 / 9",
Self::Square => "1 / 1",
Self::Vertical => "9 / 16",
Self::Tall => "4 / 5",
Self::Auto => "auto",
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Wide => "wide",
Self::Square => "square",
Self::Vertical => "vertical",
Self::Tall => "tall",
Self::Auto => "auto",
}
}
}
/// 解析结果:某个 URL 属于哪个平台、应该怎么嵌。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Resolved {
pub platform: &'static str,
pub platform_name: &'static str,
pub category: Category,
pub kind: EmbedKind,
/// 原始 URL(规范化后)。
pub url: String,
/// iframe / 媒体源;卡片为 `None`。
pub src: Option<String>,
pub aspect: Aspect,
/// 平台声明的固定像素高;`Some` 时比 `aspect` 优先。
pub height: Option<u32>,
/// 展示标题(平台名,OG / oEmbed 抓取成功时由 chat 侧覆盖)。
pub title: String,
/// 简介(联网抓取成功时由 chat 侧填入)。
pub description: Option<String>,
/// 封面缩略图(oEmbed / OG 抓取成功时由 chat 侧填入)。
pub image_url: Option<String>,
}
/// 联网补全的信息,由 chat 侧抓取后回填。
///
/// 解析与渲染都不联网:抓取到的标题 / 简介 / 封面 / iframe 地址通过
/// [`resolve::embed_json_enriched`] 合并进 embed,渲染模板仍然只由本 crate 产出。
#[derive(Debug, Clone, Default)]
pub struct Enrichment {
pub title: Option<String>,
pub description: Option<String>,
pub image_url: Option<String>,
/// oEmbed 返回的 iframe 地址。只有落在该平台自己声明的域名里才会被采用。
pub frame_src: Option<String>,
}
/// 解析 + 渲染的完整结果。
#[derive(Debug, Clone)]
pub struct Embed {
pub resolved: Resolved,
pub html: String,
}
impl Embed {
/// chat 消息 `embeds` 数组里的一项。
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"type": "platform",
"platform": self.resolved.platform,
"platform_name": self.resolved.platform_name,
"category": self.resolved.category.as_str(),
"kind": self.resolved.kind.as_str(),
"url": self.resolved.url,
"src": self.resolved.src,
"title": self.resolved.title,
"description": self.resolved.description,
"image_url": self.resolved.image_url,
"aspect": self.resolved.aspect.as_str(),
"height": self.resolved.height,
"html": self.html,
})
}
}