use url::Url; use crate::media::{self, MediaKind}; use crate::types::{EmbedAuthor, EmbedMedia, EmbedProvider, MessageEmbed}; use crate::{ResolveContext, UnfurlError, html, http, normalize, oembed}; const OEMBED_MAX_BYTES: usize = 262_144; pub async fn resolve_page( context: &ResolveContext<'_>, ) -> Result, UnfurlError> { Ok(resolve_page_final(context).await?.1) } /// 同 [`resolve_page`],但同时返回跟随重定向后的落地地址。 /// /// 短链(`b23.tv` / `t.co`)只有拿到落地页才能重新命中平台注册表并升级成播放器。 pub async fn resolve_page_final( context: &ResolveContext<'_>, ) -> Result<(Url, Option), UnfurlError> { let fetched = http::fetch( context.url.as_str(), "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8", context.options.max_bytes, ) .await?; let final_url = Url::parse(&fetched.final_url).unwrap_or_else(|_| context.url.clone()); if !(200..300).contains(&fetched.status) { return Ok((final_url, None)); } let content_type = fetched.content_type.to_ascii_lowercase(); if let Some(kind) = media::from_content_type(&content_type).or_else(|| media::from_url(&final_url)) { return Ok(( final_url.clone(), Some({ let url = &final_url; let content_type = &content_type; let media = EmbedMedia { url: Some(url.to_string()), content_type: (!content_type.is_empty()).then(|| content_type.to_owned()), ..EmbedMedia::default() }; let filename = url .path_segments() .and_then(|mut segments| segments.next_back()) .filter(|value| !value.is_empty()) .map(str::to_owned); let mut embed = MessageEmbed { embed_type: kind.embed_type().to_owned(), url: Some(url.to_string()), title: filename, image: (kind == MediaKind::Image).then(|| media.clone()), video: (kind == MediaKind::Video).then(|| media.clone()), audio: (kind == MediaKind::Audio).then(|| media.clone()), image_url: (kind == MediaKind::Image).then(|| url.to_string()), kind: Some(kind.embed_type().to_owned()), ..MessageEmbed::default() }; normalize::normalize(&mut embed); embed }), )); } if !content_type.is_empty() && !content_type.contains("html") && !content_type.contains("xml") { return Ok((final_url, None)); } let page = html::parse(&fetched.body, &final_url); let mut oembed = None; if let Some(endpoint) = page.oembed.as_deref() { oembed = oembed::fetch(endpoint, OEMBED_MAX_BYTES).await; } if oembed.is_none() && let Some(endpoint) = crate::oembed_endpoint(context.url.as_str()) { oembed = oembed::fetch(&endpoint.endpoint, OEMBED_MAX_BYTES).await; } Ok(( final_url.clone(), build_embed(context, &final_url, page, oembed), )) } fn build_embed( context: &ResolveContext<'_>, final_url: &Url, page: html::PageMetadata, oembed: Option, ) -> Option { let oembed = oembed.unwrap_or_default(); let title = oembed.title.clone().or(page.title); let description = oembed.description.clone().or(page.description); let image_url = oembed .thumbnail_url .as_deref() .and_then(|value| http::absolute(final_url, value)) .or(page.image); let image = image_url.as_ref().map(|url| EmbedMedia { url: Some(url.clone()), ..EmbedMedia::default() }); let video = page .video .as_deref() .and_then(|value| http::absolute(final_url, value)) .map(|url| EmbedMedia { url: Some(url), ..EmbedMedia::default() }); let audio = page .audio .as_deref() .and_then(|value| http::absolute(final_url, value)) .map(|url| EmbedMedia { url: Some(url), ..EmbedMedia::default() }); let player_src = oembed .html .as_deref() .and_then(oembed::iframe_src) .and_then(|src| http::absolute(final_url, &src)) .and_then(|src| frame_allowed(&src, final_url)); let player = player_src.clone().or_else(|| { page.player .clone() .and_then(|value| http::absolute(final_url, &value)) .and_then(|value| frame_allowed(&value, final_url)) }); let html = player_src.map(|src| { let src = &src; let width = oembed.width; let height = oembed.height; let width = width.unwrap_or(640); let height = height.unwrap_or(360); format!( "", crate::escape_html(src), ) }); let embed_type = if html.is_some() { "iframe" } else if video.is_some() { "video" } else if audio.is_some() { "audio" } else if image.is_some() { "image" } else { "link" }; let author = oembed.author_name.clone().map(|name| EmbedAuthor { name, url: oembed.author_url.clone(), ..EmbedAuthor::default() }); let provider_name = oembed.provider_name.clone().or(page.site_name.clone()); let provider = provider_name.map(|name| EmbedProvider { name, url: oembed.provider_url.clone(), }); let mut embed = MessageEmbed { embed_type: embed_type.to_owned(), url: Some(context.original_url.to_string()), title, description, image: image.clone(), video, audio, author, provider, html, html_width: oembed.width, html_height: oembed.height, image_url: image_url.clone(), site_name: page.site_name.clone(), src: player, height: oembed.height, kind: Some(embed_type.to_owned()), ..MessageEmbed::default() }; normalize::normalize(&mut embed); (!embed.is_empty()).then_some(embed) } const PLAYER_HOSTS: [&str; 12] = [ "youtube.com", "youtube-nocookie.com", "youtu.be", "vimeo.com", "soundcloud.com", "spotify.com", "twitch.tv", "twitter.com", "x.com", "reddit.com", "tiktok.com", "codepen.io", ]; /// iframe 只接受页面自身域名或已知播放器域名:页面自声明的 oEmbed 端点 /// 不应把任意第三方页面塞进消息流。 fn frame_allowed(src: &str, base: &Url) -> Option { let url = Url::parse(src).ok()?; if !matches!(url.scheme(), "http" | "https") { return None; } let host = url.host_str()?.trim_end_matches('.').to_ascii_lowercase(); let base_host = base.host_str()?.trim_end_matches('.').to_ascii_lowercase(); let same_site = host == base_host || host.ends_with(&format!(".{base_host}")) || base_host.ends_with(&format!(".{host}")); let known = PLAYER_HOSTS .iter() .any(|domain| host == *domain || host.ends_with(&format!(".{domain}"))); (same_site || known).then(|| url.to_string()) }