e1df092
//! oEmbed 抓取:把平台声明的端点返回的 JSON 收敛成 [`social::Enrichment`]。
//!
//! 第三方返回的 HTML 不会被注入消息流:只从 `html` 里抠出 iframe 的 `src`,而且
//! `social` 侧还会再校验一次,只有落在该平台自己声明的域名里才会被采用。抓取失败
//! 一律返回 `None`,交给 OpenGraph 兜底,也不会写进 `chat_link_unfurl` 缓存。
use social::Enrichment;
use crate::services::outbound::{fetch_following, validate_outbound_url};
const MAX_BYTES: usize = 262_144;
/// 抓取 URL 所属平台的 oEmbed 数据;平台没声明端点或抓取失败时返回 `None`。
pub async fn fetch(url: &str) -> Option<Enrichment> {
let endpoint = social::oembed_endpoint(url)?;
let body = fetch_following(
&endpoint.endpoint,
"application/json",
Some("json"),
MAX_BYTES,
)
.await
.ok()?
.body;
parse(&body)
}
/// 抓取页面自己声明的 oEmbed 端点。
///
/// 比 [`fetch`] 覆盖面大得多:注册表只硬编码了十几个平台,而页面里的
/// `<link rel="alternate" type="application/json+oembed">` 是平台自己发布的,
/// 新增平台不需要跟着改代码。
pub async fn fetch_endpoint(endpoint: &str) -> Option<Enrichment> {
if !endpoint.starts_with("http://") && !endpoint.starts_with("https://") {
return None;
}
if validate_outbound_url(endpoint).is_err() {
return None;
}
let body = fetch_following(endpoint, "application/json", Some("json"), MAX_BYTES)
.await
.ok()?
.body;
parse(&body)
}
fn string_field(value: &serde_json::Value, key: &str) -> Option<String> {
let text = value.get(key)?.as_str()?.trim();
(!text.is_empty()).then(|| text.to_string())
}
/// 从 oEmbed 的 `html` 字段里取出 iframe 的 `src`。
fn iframe_src(html: &str) -> Option<String> {
let start = html.to_ascii_lowercase().find("<iframe")?;
let tail = &html[start..];
let index = tail.to_ascii_lowercase().find("src=")? + 4;
let after = tail[index..].trim_start();
let quote = after.chars().next()?;
if quote != '"' && quote != '\'' {
return None;
}
let rest = &after[1..];
let end = rest.find(quote)?;
let src = rest[..end].trim();
(!src.is_empty()).then(|| src.to_string())
}
/// 解析 oEmbed 响应体;没有任何可用字段时返回 `None`。
pub fn parse(body: &str) -> Option<Enrichment> {
let value: serde_json::Value = serde_json::from_str(body).ok()?;
if !value.is_object() {
return None;
}
let enrichment = Enrichment {
title: string_field(&value, "title"),
description: string_field(&value, "description")
.or_else(|| string_field(&value, "author_name")),
image_url: string_field(&value, "thumbnail_url"),
frame_src: string_field(&value, "html").as_deref().and_then(iframe_src),
};
(enrichment.title.is_some()
|| enrichment.description.is_some()
|| enrichment.image_url.is_some()
|| enrichment.frame_src.is_some())
.then_some(enrichment)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_the_youtube_payload() {
let body = r#"{"title":"Rick Astley - Never Gonna Give You Up (Official Video)","author_name":"Rick Astley","author_url":"https://www.youtube.com/@RickAstleyYT","thumbnail_url":"https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg","type":"video","version":"1.0"}"#;
let enrichment = parse(body).unwrap();
assert_eq!(
enrichment.title.as_deref(),
Some("Rick Astley - Never Gonna Give You Up (Official Video)")
);
assert_eq!(enrichment.description.as_deref(), Some("Rick Astley"));
assert_eq!(
enrichment.image_url.as_deref(),
Some("https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg")
);
assert!(enrichment.frame_src.is_none());
}
#[test]
fn pulls_the_frame_out_of_the_spotify_payload() {
let body = r#"{"title":"Never Gonna Give You Up","html":"<iframe style=\"border-radius: 12px\" width=\"100%\" height=\"152\" title=\"Spotify Embed: Never Gonna Give You Up\" frameborder=\"0\" allowfullscreen allow=\"autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture\" loading=\"lazy\" src=\"https://open.spotify.com/embed/track/4cOdK2wGLETKBW3PvgPWqT?utm_source=oembed\"></iframe>"}"#;
let enrichment = parse(body).unwrap();
assert_eq!(
enrichment.frame_src.as_deref(),
Some("https://open.spotify.com/embed/track/4cOdK2wGLETKBW3PvgPWqT?utm_source=oembed")
);
}
#[test]
fn rejects_bodies_without_useful_fields() {
assert!(parse("<html>nope</html>").is_none());
assert!(parse("{}").is_none());
assert!(parse(r#"{"type":"video","html":"<blockquote>quote</blockquote>"}"#).is_none());
}
}