mirror of
https://github.com/TheFunny/TelegramTwitterMediaBot.git
synced 2026-09-23 23:32:05 +00:00
refactor(x-media): split the post title from its content
`Fetched.title` carried whatever text the platform had — a tweet's body,
a bilibili dynamic's body, a pixiv artwork's title — which was enough
while x/twitter (no title at all) set the shape. The platforms actually
disagree: pixiv has a title *and* a description, bilibili has an opus
headline *and* a body. Posts now carry both:
- `title`: the platform's title (a pixiv artwork title, a bilibili opus
headline or video card title), empty on text-only platforms;
- `content`: the body (tweet / bsky / misskey text, bilibili dynamic
body, and pixiv's description — fetched for the first time here and
flattened from the app API's HTML to plain text).
`{content}` joins the caption-format placeholders, so a custom
`/set_format` can include a pixiv description. The built-in captions keep
producing byte-identical output: `compose_text` joins the two fields the
same way the single field already was, and bilibili's forward marker
(`//@author:`) now lands in `content` behind the head line's `title`.
`CachedPost.content` is `#[serde(default)]`, so link-cache entries and
queued task payloads written before the split still parse, their text
living in `title`.
This commit is contained in:
@@ -29,7 +29,7 @@
|
||||
|
||||
use super::model;
|
||||
use crate::media::Media;
|
||||
use crate::site::{FetchError, Fetched, RenderData, Site, SiteFuture};
|
||||
use crate::site::{FetchError, Fetched, RenderData, Site, SiteFuture, compose_text};
|
||||
use html_escape::{encode_double_quoted_attribute, encode_text};
|
||||
use regex::Regex;
|
||||
use std::sync::LazyLock;
|
||||
@@ -270,7 +270,8 @@ impl From<model::Item> for Fetched {
|
||||
let url = format!("https://www.bilibili.com/opus/{}", item.id_str);
|
||||
let author = author_name(&item).to_string();
|
||||
let author_url = author_url(&item).unwrap_or_else(|| url.clone());
|
||||
let text = text_of(&item);
|
||||
let (title, content) = text_parts(&item);
|
||||
let text = compose_text(&title, &content);
|
||||
let tags = topic_name(&item).to_string();
|
||||
|
||||
let caption = caption(&url, &author_url, &author, &text);
|
||||
@@ -279,7 +280,8 @@ impl From<model::Item> for Fetched {
|
||||
Fetched {
|
||||
source_url: url.clone(),
|
||||
caption,
|
||||
title: text.clone(),
|
||||
title: title.clone(),
|
||||
content: content.clone(),
|
||||
media,
|
||||
sensitive: false,
|
||||
site_id: "bilibili",
|
||||
@@ -287,7 +289,8 @@ impl From<model::Item> for Fetched {
|
||||
url,
|
||||
author: encode_text(&author).into_owned(),
|
||||
author_url,
|
||||
title: encode_text(&text).into_owned(),
|
||||
title: encode_text(&title).into_owned(),
|
||||
content: encode_text(&content).into_owned(),
|
||||
tags: encode_text(&tags).into_owned(),
|
||||
}),
|
||||
_keep_alive: None,
|
||||
@@ -338,37 +341,33 @@ fn archive_title(item: &model::Item) -> Option<&str> {
|
||||
.filter(|title| !title.trim().is_empty())
|
||||
}
|
||||
|
||||
/// The dynamic's own words, richest source first: the opus document
|
||||
/// (headline plus body) → `module_dynamic.desc.text` → the attached video's
|
||||
/// card title.
|
||||
/// The dynamic's own words, richest source first: the opus document's
|
||||
/// headline and body → the legacy body (`module_dynamic.desc.text`) → the
|
||||
/// attached video's card title, which is the text of a 视频投稿动态 because
|
||||
/// such a post has no body anywhere.
|
||||
///
|
||||
/// The opus shape is what makes ordinary 图文 posts readable at all — their
|
||||
/// legacy serialization has no text — while a 视频投稿动态 has no body
|
||||
/// anywhere and is represented by its card title (mirroring pixiv, whose
|
||||
/// `title` is the artwork title rather than post text).
|
||||
fn own_text(item: &model::Item) -> String {
|
||||
/// legacy serialization has no text.
|
||||
fn own_parts(item: &model::Item) -> (String, String) {
|
||||
if let Some(opus) = opus(item) {
|
||||
let title = opus.title.as_deref().unwrap_or_default().trim();
|
||||
let title = opus.title.as_deref().unwrap_or_default().trim().to_string();
|
||||
let body = opus
|
||||
.summary
|
||||
.as_ref()
|
||||
.map(|summary| summary.text.trim())
|
||||
.map(|summary| summary.text.trim().to_string())
|
||||
.unwrap_or_default();
|
||||
let text = match (title.is_empty(), body.is_empty()) {
|
||||
(false, false) => format!("{title}\n{body}"),
|
||||
(false, true) => title.to_string(),
|
||||
(true, false) => body.to_string(),
|
||||
(true, true) => String::new(),
|
||||
};
|
||||
if !text.is_empty() {
|
||||
return text;
|
||||
if !title.is_empty() || !body.is_empty() {
|
||||
return (title, body);
|
||||
}
|
||||
}
|
||||
let body = desc_text(item).trim();
|
||||
if !body.is_empty() {
|
||||
return body.to_string();
|
||||
return (String::new(), body.to_string());
|
||||
}
|
||||
archive_title(item).unwrap_or_default().to_string()
|
||||
(
|
||||
archive_title(item).unwrap_or_default().to_string(),
|
||||
String::new(),
|
||||
)
|
||||
}
|
||||
|
||||
fn topic_name(item: &model::Item) -> &str {
|
||||
@@ -380,28 +379,29 @@ fn topic_name(item: &model::Item) -> &str {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The post's text: its own plus the quoted original's when it is a forward,
|
||||
/// marked the way bilibili's web UI does (`//@author:`).
|
||||
fn text_of(item: &model::Item) -> String {
|
||||
let own = own_text(item);
|
||||
/// The post's title and body: the dynamic's own, with the quoted original's
|
||||
/// text appended to the body the way bilibili's web UI shows forwards
|
||||
/// (`//@author:`).
|
||||
fn text_parts(item: &model::Item) -> (String, String) {
|
||||
let (title, mut content) = own_parts(item);
|
||||
let Some(orig) = item.orig.as_deref() else {
|
||||
return own;
|
||||
return (title, content);
|
||||
};
|
||||
let orig_text = own_text(orig);
|
||||
let (orig_title, orig_content) = own_parts(orig);
|
||||
let orig_text = compose_text(&orig_title, &orig_content);
|
||||
if orig_text.is_empty() {
|
||||
return own;
|
||||
return (title, content);
|
||||
}
|
||||
let name = author_name(orig);
|
||||
let mut text = own;
|
||||
if !text.is_empty() {
|
||||
text.push('\n');
|
||||
if !content.is_empty() {
|
||||
content.push('\n');
|
||||
}
|
||||
if name.is_empty() {
|
||||
text.push_str(&orig_text);
|
||||
content.push_str(&orig_text);
|
||||
} else {
|
||||
text.push_str(&format!("//@{name}:\n{orig_text}"));
|
||||
content.push_str(&format!("//@{name}:\n{orig_text}"));
|
||||
}
|
||||
text
|
||||
(title, content)
|
||||
}
|
||||
|
||||
/// The dynamic's media: its own grid (or video cover), falling back to the
|
||||
@@ -592,7 +592,8 @@ mod tests {
|
||||
"https://www.bilibili.com/opus/1245284537985925159"
|
||||
);
|
||||
assert_eq!(fetched.site_id, "bilibili");
|
||||
assert_eq!(fetched.title, "新歌上线");
|
||||
assert_eq!(fetched.title, "");
|
||||
assert_eq!(fetched.content, "新歌上线");
|
||||
assert!(!fetched.sensitive);
|
||||
assert_eq!(fetched.media.len(), 1);
|
||||
match &fetched.media[0] {
|
||||
@@ -627,6 +628,7 @@ mod tests {
|
||||
Some((
|
||||
"索尼音乐中国",
|
||||
"https://space.bilibili.com/486906719",
|
||||
"",
|
||||
"新歌上线",
|
||||
"音乐"
|
||||
))
|
||||
@@ -696,9 +698,10 @@ mod tests {
|
||||
};
|
||||
let fetched = parse(item);
|
||||
|
||||
assert_eq!(fetched.title, "每个人的青春里,都有一首 A-Lin");
|
||||
assert_eq!(
|
||||
fetched.title,
|
||||
"每个人的青春里,都有一首 A-Lin\n那些曾经陪你失恋的歌\n\n【活动】详情见正文"
|
||||
fetched.content,
|
||||
"那些曾经陪你失恋的歌\n\n【活动】详情见正文"
|
||||
);
|
||||
assert!(
|
||||
fetched.caption.contains("那些曾经陪你失恋的歌"),
|
||||
@@ -726,8 +729,8 @@ mod tests {
|
||||
// A body without a headline, and a headline without a body, both
|
||||
// stand alone rather than rendering an empty line.
|
||||
for (title, body, expected) in [
|
||||
(None, "只有正文", "只有正文"),
|
||||
(Some("只有标题"), "", "只有标题"),
|
||||
(None, "只有正文", ("", "只有正文")),
|
||||
(Some("只有标题"), "", ("只有标题", "")),
|
||||
] {
|
||||
let major = serde_json::json!({
|
||||
"type": "MAJOR_TYPE_OPUS",
|
||||
@@ -735,7 +738,8 @@ mod tests {
|
||||
});
|
||||
let mut json = item_json(major, "");
|
||||
json["modules"]["module_dynamic"]["desc"] = serde_json::Value::Null;
|
||||
assert_eq!(parse(json).title, expected);
|
||||
let fetched = parse(json);
|
||||
assert_eq!((fetched.title.as_str(), fetched.content.as_str()), expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -748,7 +752,8 @@ mod tests {
|
||||
draw_item("http://i0.hdslb.com/bfs/new_dyn/l.jpg"),
|
||||
"legacy 正文",
|
||||
));
|
||||
assert_eq!(fetched.title, "legacy 正文");
|
||||
assert_eq!(fetched.title, "");
|
||||
assert_eq!(fetched.content, "legacy 正文");
|
||||
assert_eq!(fetched.media.len(), 1);
|
||||
assert_eq!(
|
||||
fetched.media[0].url(),
|
||||
@@ -811,6 +816,7 @@ mod tests {
|
||||
fetched.render_fields().unwrap().2,
|
||||
"GTX760游戏性能测试,二手显卡尚能战否?"
|
||||
);
|
||||
assert_eq!(fetched.content, "");
|
||||
assert_eq!(fetched.media.len(), 1);
|
||||
|
||||
// Forwarding a video dynamic: the quoted card title lands after the
|
||||
@@ -819,8 +825,9 @@ mod tests {
|
||||
forward["modules"]["module_dynamic"]["desc"] = serde_json::Value::Null;
|
||||
forward["orig"] = item;
|
||||
let fetched = parse(forward);
|
||||
assert_eq!(fetched.title, "");
|
||||
assert_eq!(
|
||||
fetched.title,
|
||||
fetched.content,
|
||||
"//@索尼音乐中国:\nGTX760游戏性能测试,二手显卡尚能战否?"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -851,7 +858,8 @@ mod tests {
|
||||
fetched.media[0].url(),
|
||||
"https://i0.hdslb.com/bfs/new_dyn/o.jpg"
|
||||
);
|
||||
assert_eq!(fetched.title, "转发理由\n//@A-SOUL_Official:\n原动态正文");
|
||||
assert_eq!(fetched.title, "");
|
||||
assert_eq!(fetched.content, "转发理由\n//@A-SOUL_Official:\n原动态正文");
|
||||
// The forwarder stays the author; the quote appears in the text.
|
||||
assert!(
|
||||
fetched.caption.contains("索尼音乐中国"),
|
||||
@@ -870,7 +878,8 @@ mod tests {
|
||||
fn from_item_without_major_has_no_media() {
|
||||
let fetched = parse(item_json(serde_json::Value::Null, "只有文字"));
|
||||
assert!(fetched.media.is_empty());
|
||||
assert_eq!(fetched.title, "只有文字");
|
||||
assert_eq!(fetched.title, "");
|
||||
assert_eq!(fetched.content, "只有文字");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -879,7 +888,8 @@ mod tests {
|
||||
draw_item("http://i0.hdslb.com/bfs/new_dyn/a.jpg"),
|
||||
"<b>\"x\" & y</b>",
|
||||
));
|
||||
assert_eq!(fetched.title, "<b>\"x\" & y</b>");
|
||||
assert_eq!(fetched.title, "");
|
||||
assert_eq!(fetched.content, "<b>\"x\" & y</b>");
|
||||
// `encode_text` escapes markup only; a bare quote is text, not an
|
||||
// attribute delimiter, and stays as-is.
|
||||
assert!(
|
||||
@@ -887,8 +897,8 @@ mod tests {
|
||||
"{}",
|
||||
fetched.caption
|
||||
);
|
||||
let (_, _, title, _) = fetched.render_fields().unwrap();
|
||||
assert_eq!(title, "<b>\"x\" & y</b>");
|
||||
let (_, _, _, content, _) = fetched.render_fields().unwrap();
|
||||
assert_eq!(content, "<b>\"x\" & y</b>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -980,20 +990,22 @@ mod tests {
|
||||
return;
|
||||
};
|
||||
assert!(fetched.media.is_empty());
|
||||
assert!(!fetched.title.trim().is_empty());
|
||||
assert!(fetched.title.is_empty());
|
||||
assert!(!fetched.content.trim().is_empty());
|
||||
}
|
||||
|
||||
/// Regression for the reported case: this opus post's legacy
|
||||
/// serialization has `desc: null`, so without the `itemOpusStyle`
|
||||
/// request it parsed with an empty title.
|
||||
/// request it parsed with no text at all.
|
||||
#[tokio::test]
|
||||
#[ignore = "live network: requires outbound HTTPS to api.bilibili.com"]
|
||||
async fn live_fetch_opus_dynamic_has_title_and_text() {
|
||||
async fn live_fetch_opus_dynamic_has_content() {
|
||||
let Some(fetched) = live_fetch("https://www.bilibili.com/opus/1248857553488576532").await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(fetched.title, "[doge_金箍]黑白搭配");
|
||||
assert_eq!(fetched.title, "");
|
||||
assert_eq!(fetched.content, "[doge_金箍]黑白搭配");
|
||||
assert!(fetched.caption.ends_with("黑白搭配"), "{}", fetched.caption);
|
||||
let urls: Vec<&str> = fetched.media.iter().map(|m| m.url()).collect();
|
||||
assert_eq!(urls.len(), 1, "{urls:?}");
|
||||
@@ -1008,6 +1020,7 @@ mod tests {
|
||||
return;
|
||||
};
|
||||
assert!(!fetched.title.trim().is_empty(), "{fetched:?}");
|
||||
assert!(fetched.content.is_empty(), "{fetched:?}");
|
||||
assert!(
|
||||
fetched.caption.contains(&fetched.title),
|
||||
"{}",
|
||||
|
||||
Reference in New Issue
Block a user