diff --git a/apps/bright/lib/bright/oban_workers/process_posts.ex b/apps/bright/lib/bright/oban_workers/process_posts.ex index 0afae66..69f5ad8 100644 --- a/apps/bright/lib/bright/oban_workers/process_posts.ex +++ b/apps/bright/lib/bright/oban_workers/process_posts.ex @@ -10,6 +10,7 @@ defmodule Bright.ObanWorkers.ProcessPosts do alias Bright.Socials.XPost alias Bright.Streams.Stream alias Bright.Platforms.Platform + alias Bright.Platforms import Ecto.Query require Logger @@ -23,10 +24,10 @@ defmodule Bright.ObanWorkers.ProcessPosts do def perform(%Oban.Job{args: %{}}) do Logger.info(">>>> Process Posts is performing.") - known_platforms = Repo.all(Platform) + known_platforms = Platform |> Repo.all() |> Repo.preload(:platform_aliases) {num, nil} = - get_unprocessed_posts() + XPost.get_unprocessed_posts() |> then(fn posts -> if posts == [] do Logger.info("No unprocessed posts found") @@ -42,19 +43,17 @@ defmodule Bright.ObanWorkers.ProcessPosts do def process_post(post, known_platforms) do with platforms <- XPost.get_platforms_mentioned(post, known_platforms), - true <- is_nsfw_live_annoucement?(post, platforms, known_platforms), + true <- XPost.is_nsfw_live_announcement?(post, platforms, known_platforms), {:ok, _stream} <- create_stream(post, platforms) do :ok else - _ -> :ok - end - end + idk -> + Logger.debug( + "process_post did not find a nsfw live announcement. post=#{inspect(post)} known_platforms=#{inspect(known_platforms)}" + ) - def get_unprocessed_posts() do - XPost - |> where([p], is_nil(p.processed_at)) - |> preload(:vtuber) - |> Repo.all() + :ok + end end @doc """ @@ -99,43 +98,4 @@ defmodule Bright.ObanWorkers.ProcessPosts do # No posts to update defp mark_posts_as_processed(_), do: :ok - - @doc """ - Is the post a valid NSFW livestream announcement? - - Eligibility requirements. - To be considered a NSFW live announcement, a post must satisfy all the following conditions. - - * The post is authored by the lewdtuber - * The post mentions a NSFW platform - * The post does not contain any URLs to SFW streaming platforms. - - """ - def is_nsfw_live_annoucement?(%XPost{vtuber: vtuber} = post, platforms, known_platforms) do - Logger.debug("Checking if post is NSFW live announcement: #{inspect(post)}") - - nsfw_platforms = Enum.filter(known_platforms, & &1.nsfw?) - sfw_platforms = Enum.reject(known_platforms, & &1.nsfw?) - - conditions = [ - {:authored_by_vtuber?, not is_nil(vtuber)}, - {:contains_nsfw_link?, - Enum.any?(platforms, fn plat -> Enum.any?(nsfw_platforms, &match_platform?(plat, &1)) end)}, - {:no_sfw_link?, - not Enum.any?(platforms, fn plat -> - Enum.any?(sfw_platforms, &match_platform?(plat, &1)) - end)} - ] - - Enum.reduce_while(conditions, true, fn {label, condition}, _acc -> - if condition do - {:cont, true} - else - Logger.debug("NSFW announcement check failed at: #{label}") - {:halt, false} - end - end) - end - - defp match_platform?(plat, platform), do: String.contains?(plat, &URI.parse(&1.url).hostname) end diff --git a/apps/bright/lib/bright/platforms.ex b/apps/bright/lib/bright/platforms.ex index 14f95a4..ed5295c 100644 --- a/apps/bright/lib/bright/platforms.ex +++ b/apps/bright/lib/bright/platforms.ex @@ -38,8 +38,8 @@ defmodule Bright.Platforms do """ def get_platform!(id) do Platform - |> Repo.get!(Platform, id) - |> Repo.preload([:platform_aliases]) + |> Repo.get!(id) + |> Repo.preload(:platform_aliases) end @doc """ @@ -125,4 +125,15 @@ defmodule Bright.Platforms do |> PlatformAlias.changeset(attrs) |> Repo.insert() end + + def match_platform?(a, b) do + URI.parse(a.url).host === URI.parse(b.url).host + end + + @doc """ + Do any of the A platforms match any of the B platforms? + """ + def contains_platform?(a, b) do + Enum.any?(a, fn plat -> Enum.any?(b, &match_platform?(plat, &1)) end) + end end diff --git a/apps/bright/lib/bright/platforms/platform.ex b/apps/bright/lib/bright/platforms/platform.ex index 191efe2..ec2aa62 100644 --- a/apps/bright/lib/bright/platforms/platform.ex +++ b/apps/bright/lib/bright/platforms/platform.ex @@ -18,5 +18,6 @@ defmodule Bright.Platforms.Platform do platform |> cast(attrs, [:name, :url, :slug]) |> validate_required([:name, :url, :slug]) + |> unique_constraint(:name) end end diff --git a/apps/bright/lib/bright/socials.ex b/apps/bright/lib/bright/socials.ex index c0aef76..a2fbb3b 100644 --- a/apps/bright/lib/bright/socials.ex +++ b/apps/bright/lib/bright/socials.ex @@ -1,7 +1,26 @@ defmodule Bright.Socials do + alias Bright.Socials.XPost + alias Bright.Repo + @moduledoc """ Socials context, for functions for interacting with social media platforms """ + @doc """ + Creates a x_post. + ## Examples + + iex> x_post(%{field: value}) + {:ok, %XPost{}} + + iex> x_post(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_x_post(attrs \\ %{}) do + %XPost{} + |> XPost.changeset(attrs) + |> Repo.insert() + end end diff --git a/apps/bright/lib/bright/socials/x_post.ex b/apps/bright/lib/bright/socials/x_post.ex index 70f9245..1ec9d5f 100644 --- a/apps/bright/lib/bright/socials/x_post.ex +++ b/apps/bright/lib/bright/socials/x_post.ex @@ -5,6 +5,7 @@ defmodule Bright.Socials.XPost do alias Bright.Vtubers.Vtuber alias Bright.Socials.{XPost, RSSParser} alias Bright.Platforms.Platform + alias Bright.Platforms alias Quinn require Logger @@ -66,6 +67,17 @@ defmodule Bright.Socials.XPost do end end + def authored_by_vtuber?(x_post, vtuber) do + x_post.vtuber_id === vtuber.id + end + + def get_unprocessed_posts() do + XPost + |> where([p], is_nil(p.processed_at)) + |> Repo.all() + |> Repo.preload(:vtuber) + end + def extract_hostname(url) do uri = URI.parse(url) uri.host || "" @@ -113,6 +125,45 @@ defmodule Bright.Socials.XPost do def get_platforms_mentioned(raw_text, platforms) do Enum.filter(platforms, &includes_platform?(raw_text, &1)) end + + @doc """ + Is the post a valid NSFW livestream announcement? + + Eligibility requirements. + To be considered a NSFW live announcement, a post must satisfy all the following conditions. + + * The post is authored by the lewdtuber + * The post does not contain, "VOD/i" + * The post mentions a NSFW platform + * The post does not mention any SFW streaming platform. + + """ + def is_nsfw_live_announcement?( + %XPost{vtuber: vtuber} = post, + mentioned_platforms, + known_platforms + ) do + Logger.debug("Checking if post is NSFW live announcement: #{inspect(post)}") + + nsfw_platforms = Enum.filter(known_platforms, & &1.nsfw) + sfw_platforms = Enum.reject(known_platforms, & &1.nsfw) + + conditions = [ + # {:authored_by_vtuber?, XPost.authored_by_vtuber?(post, vtuber)}, # This one might not make sense. I think we only get posts from the vtuber's feed + {:not_vod?, not String.contains?(String.downcase(post.raw), "vod")}, + {:contains_nsfw_link?, Platforms.contains_platform?(mentioned_platforms, nsfw_platforms)}, + {:no_sfw_link?, not Platforms.contains_platform?(mentioned_platforms, sfw_platforms)} + ] + + Enum.reduce_while(conditions, true, fn {label, condition}, _acc -> + if condition do + {:cont, true} + else + Logger.debug("NSFW announcement check failed at: #{label}") + {:halt, false} + end + end) + end end defimpl Phoenix.HTML.Safe, for: Bright.Socials.XPost do diff --git a/apps/bright/test/bright/oban_workers/process_posts_test.exs b/apps/bright/test/bright/oban_workers/process_posts_test.exs index a8f619b..de6484b 100644 --- a/apps/bright/test/bright/oban_workers/process_posts_test.exs +++ b/apps/bright/test/bright/oban_workers/process_posts_test.exs @@ -7,47 +7,82 @@ defmodule Bright.ProcessPostsTest do alias Bright.Streams alias Bright.Streams.Stream alias Bright.Platforms.Platform + alias Bright.Platforms alias Bright.VtubersFixtures alias Bright.Socials.XPost + alias Bright.Socials setup do vtuber = Bright.VtubersFixtures.vtuber_fixture() - %Platform{ + Platforms.create_platform(%{ name: "Fansly", slug: "fansly", url: "https://fansly.com", nsfw: true - } - |> Repo.insert!() + }) - %Platform{ + Platforms.create_platform(%{ name: "Twitch", slug: "twitch", url: "https://twitch.tv", nsfw: false - } - |> Repo.insert!() + }) - %Platform{ + Platforms.create_platform(%{ name: "OnlyFans", slug: "onlyfans", url: "https://onlyfans.com", nsfw: true - } - |> Repo.insert!() + }) - posts = - for i <- 1..3 do - %XPost{ - raw: "Raw content #{i}", - url: "https://example.com/post#{i}", - date: DateTime.utc_now(:second), - processed_at: nil, - vtuber_id: vtuber.id - } - |> Repo.insert!() - end + Platforms.create_platform(%{ + name: "Chaturbate", + slug: "chaturbate", + url: "https://chaturbate.com", + nsfw: true + }) + + posts = [ + # these posts are valid nsfw livestream announcements + Socials.create_x_post(%{ + raw: "I'm going live! fansly.com/fakename <3", + url: "https://x.com/fakename/status/283498235", + date: DateTime.utc_now(:second), + processed_at: nil, + vtuber_id: vtuber.id + }), + Socials.create_x_post(%{ + raw: "gm! tiem for sex breakfast https://onlyfans.com/fakename", + url: "https://x.com/fakename/status/283498234", + date: DateTime.utc_now(:second), + processed_at: nil, + vtuber_id: vtuber.id + }), + Socials.create_x_post(%{ + raw: "ero strim rn http://chaturbate.com/fakename", + url: "https://x.com/fakename/status/283498232", + date: DateTime.utc_now(:second), + processed_at: nil, + vtuber_id: vtuber.id + }), + # these posts are NOT valid livestream invitations + Socials.create_x_post(%{ + raw: "Let's play a game http://twitch.tv/fakename", + url: "https://x.com/fakename/status/283498343", + date: DateTime.utc_now(:second), + processed_at: nil, + vtuber_id: vtuber.id + }), + Socials.create_x_post(%{ + raw: + "Be sure to follow me on my socials http://chaturbate.com/fakename http://twitch.tv/fakename http://onlyfans.com/fakename http://linktree.com/fakename", + url: "https://x.com/fakename/status/283498349", + date: DateTime.utc_now(:second), + processed_at: nil, + vtuber_id: vtuber.id + }) + ] {:ok, posts: posts, vtuber: vtuber} end diff --git a/apps/bright/test/bright/platforms_test.exs b/apps/bright/test/bright/platforms_test.exs index 18d4ef5..616b586 100644 --- a/apps/bright/test/bright/platforms_test.exs +++ b/apps/bright/test/bright/platforms_test.exs @@ -2,31 +2,29 @@ defmodule Bright.PlatformsTest do use Bright.DataCase alias Bright.Platforms + alias Bright.Platforms.Platform describe "platforms" do - alias Bright.Platforms.Platform - import Bright.PlatformsFixtures - @invalid_attrs %{name: 7, url: 7, icon: 7} + @invalid_attrs %{name: 7, url: 7} test "list_platforms/0 returns all platforms" do platform = platform_fixture() - assert Platforms.list_platforms() == [platform] + assert Platforms.list_platforms() |> length === 1 end test "get_platform!/1 returns the platform with given id" do - platform = platform_fixture() - assert Platforms.get_platform!(platform.id) == platform + platform = platform_fixture(%{name: "Chaturbate"}) + assert Platforms.get_platform!(platform.id).name == platform.name end test "create_platform/1 with valid data creates a platform" do - valid_attrs = %{name: "some name", url: "some url", icon: "<svg></svg>"} + valid_attrs = %{name: "some name", url: "some url", slug: "some_slug"} assert {:ok, %Platform{} = platform} = Platforms.create_platform(valid_attrs) assert platform.name == "some name" assert platform.url == "some url" - assert platform.icon == "<svg></svg>" end test "create_platform/1 with invalid data returns error changeset" do @@ -38,20 +36,17 @@ defmodule Bright.PlatformsTest do update_attrs = %{ name: "some updated name", - url: "https://example.com", - icon: "<svg>blah</svg>" + url: "https://example.com" } assert {:ok, %Platform{} = platform} = Platforms.update_platform(platform, update_attrs) assert platform.name == "some updated name" assert platform.url == "https://example.com" - assert platform.icon == "<svg>blah</svg>" end test "update_platform/2 with invalid data returns error changeset" do platform = platform_fixture() - assert {:error, %Ecto.Changeset{}} = Platforms.update_platform(platform, @invalid_attrs) - assert platform == Platforms.get_platform!(platform.id) + assert {:error, %Ecto.Changeset{}} = platform |> Platforms.update_platform(@invalid_attrs) end test "delete_platform/1 deletes the platform" do @@ -65,4 +60,39 @@ defmodule Bright.PlatformsTest do assert %Ecto.Changeset{} = Platforms.change_platform(platform) end end + + describe "match_platform?/2" do + test "compares a platform with another and returns true if the two are the same platform" do + platformA = %Platform{name: "Twitch", url: "https://twitch.tv"} + platformB = %Platform{name: "Chaturbate", url: "https://chaturbate.com"} + platformC = %Platform{name: "Twitch", url: "https://twitch.tv"} + assert Platforms.match_platform?(platformA, platformC) + assert not Platforms.match_platform?(platformA, platformB) + end + end + + describe "contains_platform?/2" do + @tag :unit + test "accepts a list of platforms and returns true if one of them match any of a list of given platform" do + platformA = %Platform{name: "Twitch", url: "https://twitch.tv"} + platformB = %Platform{name: "Chaturbate", url: "https://chaturbate.com"} + platformC = %Platform{name: "Twitch", url: "https://twitch.tv"} + assert Platforms.contains_platform?([platformA], [platformB, platformC]) + assert not Platforms.contains_platform?([platformA], [platformB]) + end + + @tag :unit + test "returns true if configured to return true for SFW platforms" do + platformA = %Platform{name: "Twitch", url: "https://twitch.tv"} + platformB = %Platform{name: "YouTube", url: "https://youtube.com"} + assert Platforms.contains_platform?([platformA], [platformB, platformA]) + end + + @tag :unit + test "returns false if matching against an empty list" do + platformA = %Platform{name: "Twitch", url: "https://twitch.tv"} + assert not Platforms.contains_platform?([platformA], []) + assert not Platforms.contains_platform?([], [platformA]) + end + end end diff --git a/apps/bright/test/bright/socials/x_post_test.exs b/apps/bright/test/bright/socials/x_post_test.exs index cb6ec4e..4767a15 100644 --- a/apps/bright/test/bright/socials/x_post_test.exs +++ b/apps/bright/test/bright/socials/x_post_test.exs @@ -2,8 +2,10 @@ defmodule Bright.XPostTest do use Bright.DataCase alias Bright.Socials.XPost + alias Bright.Socials + alias Bright.Vtubers alias Bright.Vtubers.Vtuber - alias Bright.XPostsFixtures + alias Bright.{SocialsFixtures, VtubersFixtures, XPostsFixtures, PlatformsFixtures} alias Bright.Platforms.{Platform, PlatformAlias} alias Bright.Platforms alias Bright.VultrAI @@ -11,6 +13,31 @@ defmodule Bright.XPostTest do @sample_feed "https://rss.app/feeds/FhPetvUY036xiFau.xml" + describe "authored_by_vtuber?/2" do + @tag :unit + test "returns true when the given post is authored by the given vtuber" do + vtuber = VtubersFixtures.vtuber_fixture(%{name: "ProjektMelody", slug: "projektmelody"}) + x_post = SocialsFixtures.x_post_fixture(%{vtuber_id: vtuber.id}) + assert XPost.authored_by_vtuber?(x_post, vtuber) + end + + @tag :unit + test "returns false when the given post is NOT authored by the given vtuber" do + vtuber = VtubersFixtures.vtuber_fixture(%{name: "ProjektMelody", slug: "projektmelody"}) + vtuberB = VtubersFixtures.vtuber_fixture(%{name: "Vex", slug: "vex"}) + + {:ok, x_post} = + Socials.create_x_post(%{ + raw: "test", + url: "https://x.com/projektmelody/status/1234", + date: DateTime.utc_now(:second), + vtuber_id: vtuber.id + }) + + assert not XPost.authored_by_vtuber?(x_post, vtuberB) + end + end + describe "get_new_posts" do @tag :integration test "get_new_posts/1 with URL" do @@ -35,6 +62,36 @@ defmodule Bright.XPostTest do end end + describe "get_unprocessed_posts/0" do + setup do + vtuber = + %Vtuber{ + display_name: "Some Vtuber", + slug: "some-vtuber" + } + |> Repo.insert!() + + posts = + for i <- 1..3 do + %XPost{ + raw: "Raw content #{i}", + url: "https://example.com/post#{i}", + date: DateTime.utc_now(:second), + processed_at: nil, + vtuber_id: vtuber.id + } + |> Repo.insert!() + end + + {:ok, posts: posts} + end + + @tag :unit + test "gets posts with nil processed_at" do + assert length(XPost.get_unprocessed_posts()) == 3 + end + end + describe "includes_alias?/2" do setup do ytmnd = @@ -289,5 +346,100 @@ defmodule Bright.XPostTest do assert Enum.sort(actual_platform_names) == Enum.sort(expected_platform_names) end + + @tag :unit + test "post with a platform alias" do + known_platforms = Platforms.list_platforms() + expected_platform_names = ["Fansly"] + + actual_platform_names = + XPost.get_platforms_mentioned( + XPostsFixtures.fixture_live_4() |> Map.get(:raw), + known_platforms + ) + |> Enum.map(& &1.name) + + assert actual_platform_names == expected_platform_names + end + end + + describe "is_nsfw_live_announcement?/3" do + setup do + vtuber = VtubersFixtures.vtuber_fixture() + + {:ok, x_post} = + Socials.create_x_post(%{ + raw: "I'm going live https://twitch.tv/bigchungus", + url: "https://x.com/bigchungus/status/1234", + date: DateTime.utc_now(:second), + vtuber_id: vtuber.id + }) + + x_post = Repo.preload(x_post, :vtuber) + known_platforms = PlatformsFixtures.known_platforms_fixture() + + mentioned_platforms = [ + PlatformsFixtures.onlyfans_fixture(), + PlatformsFixtures.chaturbate_fixture(), + PlatformsFixtures.fansly_fixture() + ] + + {:ok, + vtuber: vtuber, + x_post: x_post, + known_platforms: known_platforms, + mentioned_platforms: mentioned_platforms} + end + + @tag :integration + test "should return false when receiving a XPost linking to a SFW platform", %{ + vtuber: vtuber, + x_post: x_post, + known_platforms: known_platforms + } do + mentioned_platforms = [ + PlatformsFixtures.twitch_fixture(), + PlatformsFixtures.fansly_fixture(), + PlatformsFixtures.onlyfans_fixture(), + PlatformsFixtures.chaturbate_fixture() + ] + + assert not XPost.is_nsfw_live_announcement?(x_post, mentioned_platforms, known_platforms) + end + + test "should return true when receiving an XPost with only Chaturbate mentioned", %{ + vtuber: vtuber, + x_post: x_post, + known_platforms: known_platforms, + mentioned_platforms: mentioned_platforms + } do + mentioned_platforms = [ + PlatformsFixtures.chaturbate_fixture() + ] + + assert XPost.is_nsfw_live_announcement?(x_post, mentioned_platforms, known_platforms) + end + + test "should return true when receiving an XPost with only NSFW platforms mentioned", %{ + vtuber: vtuber, + x_post: x_post, + known_platforms: known_platforms, + mentioned_platforms: mentioned_platforms + } do + assert XPost.is_nsfw_live_announcement?(x_post, mentioned_platforms, known_platforms) + end + + test "should return false when receiving an XPost with vod/i", %{ + vtuber: vtuber, + known_platforms: known_platforms + } do + x_post = %XPost{ + raw: + "IRL JOI handcam stream! Listen to my instructions and stroke your cock for me until you cum šš¦\n\nThe rest of the VOD is available here for Tier 1 subscribers or for $10! š\nā¶ļø https://fansly.com/post/755934614" + } + + mentioned_platforms = [PlatformsFixtures.fansly_fixture()] + assert not XPost.is_nsfw_live_announcement?(x_post, mentioned_platforms, known_platforms) + end end end diff --git a/apps/bright/test/support/fixtures/platforms_fixtures.ex b/apps/bright/test/support/fixtures/platforms_fixtures.ex index 1d991cf..9a1d8ab 100644 --- a/apps/bright/test/support/fixtures/platforms_fixtures.ex +++ b/apps/bright/test/support/fixtures/platforms_fixtures.ex @@ -4,16 +4,16 @@ defmodule Bright.PlatformsFixtures do entities via the `Bright.Platforms` context. """ - def platform_fixture(attrs \\ %{}) + alias Bright.Platforms.Platform @doc """ Generate a platform. """ - def platform_fixture(attrs) do + def platform_fixture(attrs \\ %{}) do {:ok, platform} = attrs |> Enum.into(%{ - icon: "some icon", + slug: "some_slug", name: "some name", url: "some url" }) @@ -21,4 +21,49 @@ defmodule Bright.PlatformsFixtures do platform end + + def known_platforms_fixture() do + [ + twitch_fixture(), + fansly_fixture(), + chaturbate_fixture(), + onlyfans_fixture(), + linktree_fixture(), + discord_fixture(), + carrd_fixture(), + throne_fixture() + ] + end + + def twitch_fixture() do + %Platform{name: "Twitch", slug: "twitch", url: "https://twitch.tv", nsfw: false} + end + + def fansly_fixture() do + %Platform{name: "Fansly", slug: "fansly", url: "https://fansly.com", nsfw: true} + end + + def chaturbate_fixture() do + %Platform{name: "Chaturbate", slug: "chaturbate", url: "https://chaturbate.com", nsfw: true} + end + + def onlyfans_fixture() do + %Platform{name: "OnlyFans", slug: "onlyfans", url: "https://onlyfans.com", nsfw: true} + end + + def linktree_fixture() do + %Platform{name: "Linktree", slug: "linktree", url: "https://linktr.ee", nsfw: false} + end + + def discord_fixture() do + %Platform{name: "Discord", slug: "discord", url: "https://discord.com", nsfw: false} + end + + def carrd_fixture() do + %Platform{name: "Carrd", slug: "carrd", url: "https://carrd.co", nsfw: false} + end + + def throne_fixture() do + %Platform{name: "Throne", slug: "throne", url: "https://throne.com", nsfw: false} + end end diff --git a/apps/bright/test/support/fixtures/socials_fixtures.ex b/apps/bright/test/support/fixtures/socials_fixtures.ex new file mode 100644 index 0000000..d95a0f7 --- /dev/null +++ b/apps/bright/test/support/fixtures/socials_fixtures.ex @@ -0,0 +1,23 @@ +defmodule Bright.SocialsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Bright.Socials` context. + """ + + @doc """ + Generate a platform. + """ + def x_post_fixture(attrs \\ %{}) do + {:ok, x_post} = + attrs + |> Enum.into(%{ + raw: "some raw text", + url: "https://x.com/fakeuser/status/9876", + processed_at: nil, + date: DateTime.utc_now(:second) + }) + |> Bright.Socials.create_x_post() + + x_post + end +end diff --git a/apps/bright/test/support/fixtures/x_posts_fixtures.ex b/apps/bright/test/support/fixtures/x_posts_fixtures.ex index 6a7a374..c8efc3d 100644 --- a/apps/bright/test/support/fixtures/x_posts_fixtures.ex +++ b/apps/bright/test/support/fixtures/x_posts_fixtures.ex @@ -82,6 +82,14 @@ defmodule Bright.XPostsFixtures do } end + def fixture_live_4() do + %XPost{ + raw: "http://melody.buzz", + date: ~U[2025-05-05T05:05:05.000Z], + url: "https://x.com/ProjektMelody/status/5555" + } + end + @doc """ Generates a basic x_post fixture. """