8 Commits

Author SHA1 Message Date
VC
639582ba59 Merge branch '16-add-aspectratio-to-image-handling' into 'main'
: copy aspect ratio for images & video

Closes #16

See merge request veretcle/oolatoocs!40
2025-12-01 15:48:36 +00:00
VC
43ca862d5a : copy aspect ratio for images & video 2025-12-01 16:39:37 +01:00
VC
47d7fdbd42 Merge branch 'feat/refactor_embeds' into 'main'
♻️: better handle of quotes and media embedded into quotes

See merge request veretcle/oolatoocs!39
2025-12-01 12:07:42 +00:00
VC
7334fb3d09 ♻️: better handle of quotes and media embedded into quotes 2025-12-01 11:37:58 +01:00
VC
79ac915347 Merge branch 'feat/megalodon_1_1' into 'main'
⬆️: upgrade megalodon v1.1

See merge request veretcle/oolatoocs!38
2025-11-27 08:13:17 +00:00
VC
e89e6e51ec ⬆️: upgrade megalodon v1.1 2025-11-27 09:08:27 +01:00
VC
7b21a0e3a7 Merge branch 'feat/add_quotes' into 'main'
Add quotes

See merge request veretcle/oolatoocs!37
2025-11-26 06:36:08 +00:00
VC
43aa6dcd99 : add mastodon quotes 2025-11-25 21:42:10 +01:00
8 changed files with 803 additions and 958 deletions

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@
.last_tweet .last_tweet
.config.toml .config.toml
.config.json .config.json
.bsky.json

1433
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "oolatoocs" name = "oolatoocs"
version = "4.3.1" version = "4.5.0"
edition = "2021" edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -12,7 +12,6 @@ env_logger = "^0.11"
futures = "^0.3" futures = "^0.3"
html-escape = "^0.2" html-escape = "^0.2"
log = "^0.4" log = "^0.4"
megalodon = "^1.0"
oauth1-request = "^0.6" oauth1-request = "^0.6"
regex = "^1.10" regex = "^1.10"
reqwest = { version = "^0.12", features = ["json", "stream", "multipart"] } reqwest = { version = "^0.12", features = ["json", "stream", "multipart"] }
@@ -24,6 +23,7 @@ bsky-sdk = "^0.1"
atrium-api = { version = "^0.25", features = ["namespace-appbsky"] } atrium-api = { version = "^0.25", features = ["namespace-appbsky"] }
image = "^0.25" image = "^0.25"
webp = "^0.3" webp = "^0.3"
megalodon = "^1.1"
[profile.release] [profile.release]
strip = true strip = true

View File

@@ -16,10 +16,13 @@ Since 2025-01-20, Twitter is now longer supported.
What it can do: What it can do:
* Reproduces the Toot content into the Record; * Reproduces the Toot content into the Record;
* Cuts (poorly) the Toot in half in its too long for Bluesky and thread it (this is cut using a word count, not the best method, but it gets the job done); * Cuts (poorly) the Toot in half in its too long for Bluesky and thread it (this is cut using a word count, not the best method, but it gets the job done);
* Reuploads images/gifs/videos from Mastodon to Bluesky * Reuploads images/gifs/videos/webcards from Mastodon to Bluesky
* ⚠️ Bluesky does not support mixing images and videos. You can have up to 4 images on a Bsky record **or** 1 video but not mix around. If you do so, only the video will be posted on Bluesky. * ⚠️ Bluesky does not support mixing images and videos. You can have up to 4 images on a Bsky record **or** 1 video but not mix around. If you do so, only the video will be posted on Bluesky.
* ⚠️ Bluesky does not support images greater than 1Mb (that is 1,000,000 bytes or 976.6 KiB), so Oolatoocs converts the image to WebP and progressively reduces the quality to fit that limitation. * ⚠️ Bluesky does not support images greater than 1Mb (that is 1,000,000 bytes or 976.6 KiB), so Oolatoocs converts the image to WebP and progressively reduces the quality to fit that limitation.
* ⚠️ Bluesky does not support webcards with any other media/quote, so webcards have the last priority
* Can reproduce threads from Mastodon to Bluesky * Can reproduce threads from Mastodon to Bluesky
* Can reproduce (self-)quotes from Mastodon to Bluesky
* ⚠️ Bluesky cant do quotes with webcards, you can only embed images **or** a video with quotes
* ⚠️ Bluesky does support polls for now. So the poll itself is just presented as text from Mastodon instead which is not the most elegant. * ⚠️ Bluesky does support polls for now. So the poll itself is just presented as text from Mastodon instead which is not the most elegant.
* Can prevent a Toot from being recorded to Bluesky by using the #NoTweet (case-insensitive) hashtag in Mastodon * Can prevent a Toot from being recorded to Bluesky by using the #NoTweet (case-insensitive) hashtag in Mastodon

View File

@@ -1,4 +1,4 @@
use crate::config::BlueskyConfig; use crate::{config::BlueskyConfig, utils::convert_aspect_ratio, OolatoocsError};
use atrium_api::{ use atrium_api::{
app::bsky::feed::post::RecordData, com::atproto::repo::upload_blob::Output, app::bsky::feed::post::RecordData, com::atproto::repo::upload_blob::Output,
types::string::Datetime, types::string::Language, types::string::RecordKey, types::string::Datetime, types::string::Language, types::string::RecordKey,
@@ -148,21 +148,38 @@ async fn get_record(
Ok(record) Ok(record)
} }
/// Generate an embed card record into Bsky /// Generate an quote embed record
/// it is encapsulated in Option to prevent this function from failing
pub async fn generate_quote_records(
config: &BlueskyConfig,
quote_id: &str,
) -> Result<atrium_api::app::bsky::feed::post::RecordEmbedRefs, Box<dyn Error>> {
// if we cant match the quote_id, simply return None
let quote_record = get_record(&config.handle, &rkey(quote_id)).await?;
Ok(
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedRecordMain(Box::new(
atrium_api::app::bsky::embed::record::MainData {
record: atrium_api::com::atproto::repo::strong_ref::MainData {
cid: quote_record.data.cid.unwrap(),
uri: quote_record.data.uri.to_owned(),
}
.into(),
}
.into(),
)),
)
}
/// Generate an embed webcard record into Bsky
/// If the preview image does not exist or fails to upload, it is simply ignored /// If the preview image does not exist or fails to upload, it is simply ignored
pub async fn generate_embed_records( pub async fn generate_webcard_records(
bsky: &BskyAgent, bsky: &BskyAgent,
card: &Card, card: &Card,
) -> Option<atrium_api::types::Union<atrium_api::app::bsky::feed::post::RecordEmbedRefs>> { ) -> Result<atrium_api::app::bsky::feed::post::RecordEmbedRefs, Box<dyn Error + Send + Sync>> {
// uploads the image card, if it fails, simply ignore everything let blob = match &card.image {
let blob = if let Some(url) = &card.image { Some(url) => upload_media(true, bsky, url).await?.blob.clone().into(),
if let Ok(image_blob) = upload_media(true, bsky, url).await { None => None,
Some(image_blob.blob.clone())
} else {
None
}
} else {
None
}; };
let record_card = atrium_api::app::bsky::embed::external::ExternalData { let record_card = atrium_api::app::bsky::embed::external::ExternalData {
@@ -172,14 +189,14 @@ pub async fn generate_embed_records(
uri: card.url.clone(), uri: card.url.clone(),
}; };
Some(atrium_api::types::Union::Refs( Ok(
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedExternalMain(Box::new( atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedExternalMain(Box::new(
atrium_api::app::bsky::embed::external::MainData { atrium_api::app::bsky::embed::external::MainData {
external: record_card.into(), external: record_card.into(),
} }
.into(), .into(),
)), )),
)) )
} }
/// Generate an array of Bsky media records /// Generate an array of Bsky media records
@@ -188,11 +205,7 @@ pub async fn generate_embed_records(
pub async fn generate_media_records( pub async fn generate_media_records(
bsky: &BskyAgent, bsky: &BskyAgent,
media_attach: &[Attachment], media_attach: &[Attachment],
) -> Option<atrium_api::types::Union<atrium_api::app::bsky::feed::post::RecordEmbedRefs>> { ) -> Result<atrium_api::app::bsky::feed::post::RecordEmbedRefs, Box<dyn Error + Send + Sync>> {
let mut embed: Option<
atrium_api::types::Union<atrium_api::app::bsky::feed::post::RecordEmbedRefs>,
> = None;
let image_media_attach: Vec<_> = media_attach let image_media_attach: Vec<_> = media_attach
.iter() .iter()
.filter(|x| x.r#type == AttachmentType::Image) .filter(|x| x.r#type == AttachmentType::Image)
@@ -208,24 +221,24 @@ pub async fn generate_media_records(
if !video_media_attach.is_empty() { if !video_media_attach.is_empty() {
// treat only the very first video, ignore the rest // treat only the very first video, ignore the rest
let media = &video_media_attach[0]; let media = &video_media_attach[0];
let blob = upload_media(false, bsky, &media.url).await.unwrap(); let blob = upload_media(false, bsky, &media.url).await?;
embed = Some(atrium_api::types::Union::Refs( return Ok(
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedVideoMain(Box::new( atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedVideoMain(Box::new(
atrium_api::app::bsky::embed::video::MainData { atrium_api::app::bsky::embed::video::MainData {
alt: media.description.clone(), alt: media.description.clone(),
aspect_ratio: None, aspect_ratio: convert_aspect_ratio(
&media.meta.as_ref().and_then(|m| m.original.clone()),
),
captions: None, captions: None,
video: blob.data.blob, video: blob.data.blob,
} }
.into(), .into(),
)), )),
)); );
// returns immediately, we dont want to treat the other medias
return embed;
} }
// It wasnt a video, then its an image or a gallery of 4 images
let mut stream = stream::iter(image_media_attach) let mut stream = stream::iter(image_media_attach)
.map(|media| { .map(|media| {
let bsky = bsky.clone(); let bsky = bsky.clone();
@@ -237,7 +250,9 @@ pub async fn generate_media_records(
.description .description
.clone() .clone()
.map_or("".to_string(), |v| v.to_owned()), .map_or("".to_string(), |v| v.to_owned()),
aspect_ratio: None, aspect_ratio: convert_aspect_ratio(
&media.meta.as_ref().and_then(|m| m.original.clone()),
),
image: i.data.blob, image: i.data.blob,
} }
}) })
@@ -256,14 +271,14 @@ pub async fn generate_media_records(
} }
if !images.is_empty() { if !images.is_empty() {
embed = Some(atrium_api::types::Union::Refs( return Ok(
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedImagesMain(Box::new( atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedImagesMain(Box::new(
atrium_api::app::bsky::embed::images::MainData { images }.into(), atrium_api::app::bsky::embed::images::MainData { images }.into(),
)), )),
)); );
} }
embed Err(OolatoocsError::new("Cannot embed media").into())
} }
async fn upload_media( async fn upload_media(

View File

@@ -19,7 +19,8 @@ use utils::{generate_multi_tweets, strip_everything};
mod bsky; mod bsky;
use bsky::{ use bsky::{
build_post_record, generate_embed_records, generate_media_records, get_session, BskyReply, build_post_record, generate_media_records, generate_quote_records, generate_webcard_records,
get_session, BskyReply,
}; };
use rusqlite::Connection; use rusqlite::Connection;
@@ -93,7 +94,9 @@ pub async fn run(config: &Config) {
true => toot.tags.clone(), true => toot.tags.clone(),
false => vec![], false => vec![],
}; };
let Ok(mut tweet_content) = strip_everything(&toot.content, &toot_tags) else { let Ok(mut tweet_content) =
strip_everything(&toot.content, &toot_tags, &config.mastodon.base)
else {
continue; // skip in case we cant strip something continue; // skip in case we cant strip something
}; };
@@ -158,15 +161,69 @@ pub async fn run(config: &Config) {
}); });
}; };
// treats medias // handle quote if any
let mut record_embed = generate_media_records(&bluesky, &toot.media_attachments).await; let quote_embed = match toot.reblog {
Some(r) => {
// treats embed cards if any let quote_record = read_state(&conn, Some(r.id.parse::<u64>().unwrap()));
if let Some(card) = &toot.card { match quote_record {
if record_embed.is_none() { Ok(Some(q)) => generate_quote_records(&config.bluesky, &q.record_uri)
record_embed = generate_embed_records(&bluesky, card).await; .await
.ok(),
_ => None,
}
} }
} None => None,
};
// handle medias if any
let media_embed = if toot.media_attachments.len() > usize::from(0u8) {
generate_media_records(&bluesky, &toot.media_attachments)
.await
.ok()
} else {
None
};
// handle webcard if any
let webcard_embed = match toot.card {
Some(t) => generate_webcard_records(&bluesky, &t).await.ok(),
None => None,
};
let record_embed = if quote_embed.is_some() {
if media_embed.is_some() {
let medias_mapped = match media_embed.unwrap() {
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedImagesMain(a) => atrium_api::app::bsky::embed::record_with_media::MainMediaRefs::AppBskyEmbedImagesMain(a),
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedVideoMain(a) => atrium_api::app::bsky::embed::record_with_media::MainMediaRefs::AppBskyEmbedVideoMain(a),
_ => continue, // this should NEVER happen as Media are either Video or
// Images at this point
};
let quote_mapped = match quote_embed.unwrap() {
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedRecordMain(
a,
) => a,
_ => continue, // again, this should NEVER happen
};
Some(atrium_api::types::Union::Refs(
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedRecordWithMediaMain(
Box::new(
atrium_api::app::bsky::embed::record_with_media::MainData {
media: atrium_api::types::Union::Refs(medias_mapped),
record: (*quote_mapped),
}.into()
)
)
))
} else {
quote_embed.map(atrium_api::types::Union::Refs)
}
} else if media_embed.is_some() {
media_embed.map(atrium_api::types::Union::Refs)
} else if webcard_embed.is_some() {
webcard_embed.map(atrium_api::types::Union::Refs)
} else {
None
};
// posts corresponding tweet // posts corresponding tweet
let record = build_post_record( let record = build_post_record(

View File

@@ -1,7 +1,7 @@
use crate::config::MastodonConfig; use crate::config::MastodonConfig;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use megalodon::{ use megalodon::{
entities::{Status, StatusVisibility}, entities::{QuotedStatus, Status, StatusVisibility},
generator, generator,
mastodon::mastodon::Mastodon, mastodon::mastodon::Mastodon,
megalodon::AppInputOptions, megalodon::AppInputOptions,
@@ -55,9 +55,19 @@ pub async fn get_mastodon_timeline_since(
.clone() .clone()
.is_some_and(|r| r == t.account.id) .is_some_and(|r| r == t.account.id)
}) })
.filter(|t| t.visibility == StatusVisibility::Public) // excludes everything that isnt .filter(|t| t.visibility == StatusVisibility::Public) // excludes everything that isnt public
// public .filter(|t| t.reblog.is_none()) // exclude reblogs
.filter(|t| t.reblog.is_none()) // excludes reblogs .filter(|t| {
// exclude quotes that arent ours
t.quote.is_none()
|| t.quote.clone().is_some_and(|r| match r {
QuotedStatus::Quote(q) => q
.quoted_status
.clone()
.is_some_and(|iq| iq.account.id == t.account.id),
_ => false,
})
})
.cloned() .cloned()
.collect(); .collect();

View File

@@ -1,7 +1,8 @@
use atrium_api::{app::bsky::embed::defs::AspectRatioData, types::Object};
use html_escape::decode_html_entities; use html_escape::decode_html_entities;
use megalodon::entities::status::Tag; use megalodon::entities::{attachment::MetaSub, status::Tag};
use regex::Regex; use regex::Regex;
use std::error::Error; use std::{error::Error, num::NonZeroU64};
/// Generate 2 contents out of 1 if that content is > 300 chars, None else /// Generate 2 contents out of 1 if that content is > 300 chars, None else
pub fn generate_multi_tweets(content: &str) -> Option<(String, String)> { pub fn generate_multi_tweets(content: &str) -> Option<(String, String)> {
@@ -53,10 +54,16 @@ fn twitter_count(content: &str) -> usize {
count count
} }
pub fn strip_everything(content: &str, tags: &Vec<Tag>) -> Result<String, Box<dyn Error>> { pub fn strip_everything(
content: &str,
tags: &Vec<Tag>,
mastodon_base: &str,
) -> Result<String, Box<dyn Error>> {
let mut res = strip_html_tags(&content.replace("</p><p>", "\n\n").replace("<br />", "\n")); let mut res = strip_html_tags(&content.replace("</p><p>", "\n\n").replace("<br />", "\n"));
strip_mastodon_tags(&mut res, tags).unwrap(); strip_quote_header(&mut res, mastodon_base)?;
strip_mastodon_tags(&mut res, tags)?;
res = res.trim_end_matches('\n').trim_end_matches(' ').to_string(); res = res.trim_end_matches('\n').trim_end_matches(' ').to_string();
res = decode_html_entities(&res).to_string(); res = decode_html_entities(&res).to_string();
@@ -64,6 +71,16 @@ pub fn strip_everything(content: &str, tags: &Vec<Tag>) -> Result<String, Box<dy
Ok(res) Ok(res)
} }
fn strip_quote_header(content: &mut String, mastodon_base: &str) -> Result<(), Box<dyn Error>> {
let re = Regex::new(&format!(
r"^RE: {}\S+\n\n",
mastodon_base.replace(".", r"\.")
))?;
*content = re.replace(content, "").to_string();
Ok(())
}
fn strip_mastodon_tags(content: &mut String, tags: &Vec<Tag>) -> Result<(), Box<dyn Error>> { fn strip_mastodon_tags(content: &mut String, tags: &Vec<Tag>) -> Result<(), Box<dyn Error>> {
for tag in tags { for tag in tags {
let re = Regex::new(&format!("(?i)(#{} ?)", &tag.name))?; let re = Regex::new(&format!("(?i)(#{} ?)", &tag.name))?;
@@ -94,10 +111,119 @@ fn strip_html_tags(input: &str) -> String {
data data
} }
pub fn convert_aspect_ratio(m: &Option<MetaSub>) -> Option<Object<AspectRatioData>> {
match m {
Some(ms) => {
if ms.height.is_some_and(|x| x > 0) && ms.width.is_some_and(|x| x > 0) {
Some(
AspectRatioData {
// unwrap is safe here
height: NonZeroU64::new(ms.height.unwrap().into()).unwrap(),
width: NonZeroU64::new(ms.width.unwrap().into()).unwrap(),
}
.into(),
)
} else {
None
}
}
None => None,
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn test_convert_aspect_ratio() {
// test None orig aspect ratio
let metasub: Option<MetaSub> = None;
let result = convert_aspect_ratio(&metasub);
assert_eq!(result, None);
// test complet with image
let metasub = Some(MetaSub {
width: Some(1920),
height: Some(1080),
size: Some(String::from("1920x1080")),
aspect: Some(1.7777777777777777),
frame_rate: None,
duration: None,
bitrate: None,
});
let expected_result = Some(
AspectRatioData {
height: NonZeroU64::new(1080).unwrap(),
width: NonZeroU64::new(1920).unwrap(),
}
.into(),
);
let result = convert_aspect_ratio(&metasub);
assert_eq!(result, expected_result);
// test complete with video
let metasub = Some(MetaSub {
width: Some(500),
height: Some(278),
size: None,
aspect: None,
frame_rate: Some(String::from("10/1")),
duration: Some(0.9),
bitrate: Some(973191),
});
let expected_result = Some(
AspectRatioData {
height: NonZeroU64::new(278).unwrap(),
width: NonZeroU64::new(500).unwrap(),
}
.into(),
);
let result = convert_aspect_ratio(&metasub);
assert_eq!(result, expected_result);
/* test broken shit
* that should never happened but you never know
*/
// zero width
let metasub = Some(MetaSub {
width: Some(0),
height: Some(278),
size: None,
aspect: None,
frame_rate: Some(String::from("10/1")),
duration: Some(0.9),
bitrate: Some(973191),
});
let result = convert_aspect_ratio(&metasub);
assert_eq!(result, None);
// None height
let metasub = Some(MetaSub {
width: Some(500),
height: None,
size: None,
aspect: None,
frame_rate: Some(String::from("10/1")),
duration: Some(0.9),
bitrate: Some(973191),
});
let result = convert_aspect_ratio(&metasub);
assert_eq!(result, None);
}
#[test] #[test]
fn test_twitter_count() { fn test_twitter_count() {
let content = "tamerelol?! 🐵"; let content = "tamerelol?! 🐵";
@@ -186,9 +312,19 @@ mod tests {
#[test] #[test]
fn test_strip_everything() { fn test_strip_everything() {
// a classic toot
let content = "<p>Ce soir à 21h, c&#39;est le Dojobar ! Au programme ce soir, une rétrospective sur la série Mario &amp; Luigi.<br />Comme d&#39;hab, le Twitch sera ici : <a href=\"https://twitch.tv/nintendojofr\" target=\"_blank\" rel=\"nofollow noopener noreferrer\" translate=\"no\"><span class=\"invisible\">https://</span><span class=\"\">twitch.tv/nintendojofr</span><span class=\"invisible\"></span></a><br />Ou juste l&#39;audio là : <a href=\"https://nintendojo.fr/dojobar\" target=\"_blank\" rel=\"nofollow noopener noreferrer\" translate=\"no\"><span class=\"invisible\">https://</span><span class=\"\">nintendojo.fr/dojobar</span><span class=\"invisible\"></span></a><br />A toute !</p>"; let content = "<p>Ce soir à 21h, c&#39;est le Dojobar ! Au programme ce soir, une rétrospective sur la série Mario &amp; Luigi.<br />Comme d&#39;hab, le Twitch sera ici : <a href=\"https://twitch.tv/nintendojofr\" target=\"_blank\" rel=\"nofollow noopener noreferrer\" translate=\"no\"><span class=\"invisible\">https://</span><span class=\"\">twitch.tv/nintendojofr</span><span class=\"invisible\"></span></a><br />Ou juste l&#39;audio là : <a href=\"https://nintendojo.fr/dojobar\" target=\"_blank\" rel=\"nofollow noopener noreferrer\" translate=\"no\"><span class=\"invisible\">https://</span><span class=\"\">nintendojo.fr/dojobar</span><span class=\"invisible\"></span></a><br />A toute !</p>";
let expected_result = "Ce soir à 21h, c'est le Dojobar ! Au programme ce soir, une rétrospective sur la série Mario & Luigi.\nComme d'hab, le Twitch sera ici : https://twitch.tv/nintendojofr\nOu juste l'audio là : https://nintendojo.fr/dojobar\nA toute !".to_string(); let expected_result = "Ce soir à 21h, c'est le Dojobar ! Au programme ce soir, une rétrospective sur la série Mario & Luigi.\nComme d'hab, le Twitch sera ici : https://twitch.tv/nintendojofr\nOu juste l'audio là : https://nintendojo.fr/dojobar\nA toute !".to_string();
let result = strip_everything(content, &vec![]).unwrap(); let result = strip_everything(content, &vec![], "https://m.nintendojo.fr").unwrap();
assert_eq!(result, expected_result);
// a quoted toot
let content = "<p class=\"quote-inline\">RE: <a href=\"https://m.nintendojo.fr/@nintendojofr/115446347351491651\" target=\"_blank\" rel=\"nofollow noopener\" translate=\"no\"><span class=\"invisible\">https://</span><span class=\"ellipsis\">m.nintendojo.fr/@nintendojofr/</span><span class=\"invisible\">115446347351491651</span></a></p><p>Assassins Creed Shadows pèsera environ 62,8 Go sur Switch 2 (et un peu plus de 100 Go sur les autres supports), soit tout juste pour rentrer sur une cartouche de 64 Go.</p><p>Ou pas, pour rappel…</p><p><a href=\"https://m.nintendojo.fr/tags/AssassinsCreedShadows\" class=\"mention hashtag\" rel=\"tag\">#<span>AssassinsCreedShadows</span></a> <a href=\"https://m.nintendojo.fr/tags/Ubisoft\" class=\"mention hashtag\" rel=\"tag\">#<span>Ubisoft</span></a> <a href=\"https://m.nintendojo.fr/tags/NintendoSwitch2\" class=\"mention hashtag\" rel=\"tag\">#<span>NintendoSwitch2</span></a></p>";
let expected_result = "Assassins Creed Shadows pèsera environ 62,8 Go sur Switch 2 (et un peu plus de 100 Go sur les autres supports), soit tout juste pour rentrer sur une cartouche de 64 Go.\n\nOu pas, pour rappel…\n\n#AssassinsCreedShadows #Ubisoft #NintendoSwitch2";
let result = strip_everything(content, &vec![], "https://m.nintendojo.fr").unwrap();
assert_eq!(result, expected_result); assert_eq!(result, expected_result);
} }