Files

1051 lines
34 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
import os
from typing import Any, Callable, cast
import pytest
from twscrape import API, gather
from twscrape.models import (
AppCard,
Article,
AudiospaceCard,
BroadcastCard,
LiveEventCard,
MessageMeCard,
PeriscopeBroadcastCard,
PollCard,
PromoImageConvoCard,
PromoVideoConvoCard,
SummaryCard,
Trend,
Tweet,
User,
UserRef,
parse_tweet,
parse_tweets,
parse_user,
)
from twscrape.utils import find_obj, to_old_rep
BASE_DIR = os.path.dirname(__file__)
DATA_DIR = os.path.join(BASE_DIR, "mocked-data")
os.makedirs(DATA_DIR, exist_ok=True)
def get_api():
api = API()
# To be sure all tests are mocked
api.pool = None # type: ignore
return api
class FakeRep:
text: str
def __init__(self, text: str):
self.text = text
def json(self):
return json.loads(self.text)
def fake_rep(filename: str):
filename = filename if filename.endswith(".json") else f"{filename}.json"
filename = filename if filename.startswith("/") else os.path.join(DATA_DIR, filename)
with open(filename) as fp:
return FakeRep(fp.read())
def mock_rep(fn: Callable[..., Any], filename: str, as_generator=False):
rep = fake_rep(filename)
async def cb_rep(*args, **kwargs):
return rep
async def cb_gen(*args, **kwargs):
yield rep
owner = getattr(fn, "__self__")
name = getattr(fn, "__name__")
cb = cast(Any, cb_gen if as_generator else cb_rep)
cb.__name__ = name
cb.__self__ = owner
setattr(owner, name, cb)
@pytest.mark.parametrize("path", ["core", "author_results"])
@pytest.mark.parametrize("user_format", ["legacy", "flat"])
def test_tweet_parser_uses_embedded_author_when_users_map_is_missing(path, user_format):
obj = to_old_rep(fake_rep("raw_search").json())
tweet = next(x for x in obj["tweets"].values() if x.get("user_id_str") in obj["users"])
user_id = tweet["user_id_str"]
user = obj["users"].pop(user_id)
tweet.pop("core", None)
tweet.pop("author_results", None)
embedded = {"__typename": "User", "rest_id": user_id, "legacy": user}
if user_format == "flat":
embedded = {k: v for k, v in user.items() if k not in {"core", "legacy", "rest_id"}}
if path == "core":
tweet["core"] = {"user_results": {"result": embedded}}
else:
tweet["author_results"] = {"result": embedded}
parsed = Tweet.parse(tweet, obj)
assert parsed.user.id_str == user_id
def check_tweet(doc: Tweet | None):
assert doc is not None
assert isinstance(doc.id, int)
assert isinstance(doc.id_str, str)
assert str(doc.id) == doc.id_str
assert doc.url is not None
assert doc.id_str in doc.url
assert doc.user is not None
assert isinstance(doc.conversationId, int)
assert isinstance(doc.conversationIdStr, str)
assert str(doc.conversationId) == doc.conversationIdStr
if doc.inReplyToTweetId is not None:
assert isinstance(doc.inReplyToTweetId, int)
assert isinstance(doc.inReplyToTweetIdStr, str)
assert str(doc.inReplyToTweetId) == doc.inReplyToTweetIdStr
if doc.inReplyToUser:
check_user_ref(doc.inReplyToUser)
if doc.mentionedUsers:
for x in doc.mentionedUsers:
check_user_ref(x)
obj = doc.dict()
assert doc.id == obj["id"]
assert doc.id_str == obj["id_str"]
assert doc.user.id == obj["user"]["id"]
assert "url" in obj
assert "_type" in obj
assert obj["_type"] == "snscrape.modules.twitter.Tweet"
assert "url" in obj["user"]
assert "_type" in obj["user"]
assert obj["user"]["_type"] == "snscrape.modules.twitter.User"
txt = doc.json()
assert isinstance(txt, str)
assert str(doc.id) in txt
if doc.media is not None:
if len(doc.media.photos) > 0:
assert doc.media.photos[0].url is not None
if len(doc.media.videos) > 0:
for x in doc.media.videos:
assert x.thumbnailUrl is not None
assert x.duration is not None
for v in x.variants:
assert v.url is not None
assert v.bitrate is not None
assert v.contentType is not None
if doc.retweetedTweet is not None:
try:
assert doc.rawContent.endswith(doc.retweetedTweet.rawContent), "content should be full"
except AssertionError as e:
print("\n" + "-" * 60)
print(doc.url)
print("1:", doc.rawContent)
print("2:", doc.retweetedTweet.rawContent)
print("-" * 60)
raise e
check_user(doc.user)
assert doc.bookmarkedCount is not None
def check_user(doc: User):
assert doc.id is not None
assert isinstance(doc.id, int)
assert isinstance(doc.id_str, str)
assert str(doc.id) == doc.id_str
assert doc.username is not None
assert isinstance(doc.profileImageUrl, str)
assert doc.profileImageUrl != ""
assert doc.descriptionLinks is not None
assert doc.pinnedIds is not None
if doc.pinnedIds:
for x in doc.pinnedIds:
assert isinstance(x, int)
if len(doc.descriptionLinks) > 0:
for x in doc.descriptionLinks:
assert x.url is not None
assert x.text is not None
assert x.tcourl is not None
obj = doc.dict()
assert doc.id == obj["id"]
assert doc.username == obj["username"]
txt = doc.json()
assert isinstance(txt, str)
assert str(doc.id) in txt
def collect_tweet_users(doc: Tweet | None):
if doc is None:
return []
users = [doc.user]
if doc.retweetedTweet is not None:
users.extend(collect_tweet_users(doc.retweetedTweet))
if doc.quotedTweet is not None:
users.extend(collect_tweet_users(doc.quotedTweet))
return users
def check_user_field_coverage(users: list[User]):
assert len(users) > 0
assert any(x.location != "" for x in users)
assert any(x.profileBannerUrl is not None for x in users)
assert any(isinstance(x.protected, bool) for x in users)
assert any(isinstance(x.verified, bool) for x in users)
def check_user_ref(doc: UserRef):
assert isinstance(doc.id, int)
assert isinstance(doc.id_str, str)
assert str(doc.id) == doc.id_str
assert doc.username is not None
assert doc.displayname is not None
obj = doc.dict()
assert doc.id == obj["id"]
assert doc.id_str == obj["id_str"]
def check_trend(doc: Trend):
assert doc.name is not None
assert doc.trend_url is not None
assert doc.trend_metadata is not None
assert isinstance(doc.grouped_trends, list) or doc.grouped_trends is None
assert doc.trend_url.url is not None
assert doc.trend_url.urlType is not None
async def test_search():
api = get_api()
mock_rep(api.search_raw, "raw_search", as_generator=True)
items = await gather(api.search("elon musk lang:en", limit=20))
assert len(items) > 0
bookmarks_count = 0
users = []
for doc in items:
check_tweet(doc)
bookmarks_count += doc.bookmarkedCount
users.extend(collect_tweet_users(doc))
assert bookmarks_count > 0, (
"`bookmark_fields` key is changed or unlucky search data. "
"Run: uv run scripts/update-mocked-data.py fetch --only search"
)
check_user_field_coverage(users)
async def test_user_by_id():
api = get_api()
mock_rep(api.user_by_id_raw, "raw_user_by_id")
doc = await api.user_by_id(2244994945)
assert doc is not None
assert doc.id == 2244994945
assert doc.username == "XDevelopers"
assert doc.blueType == "Business"
obj = doc.dict()
assert doc.id == obj["id"]
assert doc.username == obj["username"]
txt = doc.json()
assert isinstance(txt, str)
assert str(doc.id) in txt
async def test_user_by_login():
api = get_api()
mock_rep(api.user_by_login_raw, "raw_user_by_login")
doc = await api.user_by_login("xdevelopers")
assert doc is not None
assert doc.id == 2244994945
assert doc.username == "XDevelopers"
assert doc.blueType == "Business"
obj = doc.dict()
assert doc.id == obj["id"]
assert doc.username == obj["username"]
txt = doc.json()
assert isinstance(txt, str)
assert str(doc.id) in txt
async def test_user_about():
api = get_api()
mock_rep(api.user_about_raw, "raw_user_about")
doc = await api.user_about("xdevelopers")
assert doc is not None
assert doc.screen_name == "XDevelopers"
assert isinstance(doc.rest_id, int)
assert isinstance(doc.name, str) and len(doc.name) > 0
assert doc.account_based_in is not None
assert doc.location_accurate is not None
assert doc.affiliate_username is not None
assert doc.source is not None
assert isinstance(doc.username_changes, int)
assert isinstance(doc.username_last_changed_at, int)
assert doc.is_identity_verified is not None
assert isinstance(doc.verified_since_msec, int)
obj = doc.dict()
assert doc.screen_name == obj["screen_name"]
txt = doc.json()
assert isinstance(txt, str)
assert doc.screen_name in txt
async def test_tweet_details():
api = get_api()
mock_rep(api.tweet_details_raw, "raw_tweet_details")
doc = await api.tweet_details(1649191520250245121)
assert doc is not None, "tweet should not be None"
check_tweet(doc)
assert doc.id == 1649191520250245121
assert doc.user is not None, "tweet.user should not be None"
check_user_field_coverage(collect_tweet_users(doc))
async def test_tweet_replies():
api = get_api()
mock_rep(api.tweet_replies_raw, "raw_tweet_replies", as_generator=True)
twid = 1649191520250245121
tweets = await gather(api.tweet_replies(twid, limit=20))
assert len(tweets) > 0
for doc in tweets:
check_tweet(doc)
assert doc.inReplyToTweetId == twid
async def test_tweet_thread():
api = get_api()
mock_rep(api.tweet_thread_raw, "raw_tweet_thread", as_generator=True)
twid = 1649191520250245121
tweets = await gather(api.tweet_thread(twid, limit=20))
assert len(tweets) > 0
for doc in tweets:
check_tweet(doc)
assert doc.conversationId == twid
async def test_followers():
api = get_api()
mock_rep(api.followers_raw, "raw_followers", as_generator=True)
users = await gather(api.followers(2244994945))
assert len(users) > 0
for doc in users:
check_user(doc)
async def test_verified_followers():
api = get_api()
mock_rep(api.verified_followers_raw, "raw_verified_followers", as_generator=True)
users = await gather(api.verified_followers(2244994945))
assert len(users) > 0
for doc in users:
check_user(doc)
assert doc.blue is True, "snould be only Blue users"
async def test_subscriptions():
api = get_api()
mock_rep(api.subscriptions_raw, "raw_subscriptions", as_generator=True)
users = await gather(api.subscriptions(44196397))
assert len(users) > 0
for doc in users:
check_user(doc)
async def test_following():
api = get_api()
mock_rep(api.following_raw, "raw_following", as_generator=True)
users = await gather(api.following(2244994945))
assert len(users) > 0
for doc in users:
check_user(doc)
async def test_retweters():
api = get_api()
mock_rep(api.retweeters_raw, "raw_retweeters", as_generator=True)
users = await gather(api.retweeters(1649191520250245121))
assert len(users) > 0
for doc in users:
check_user(doc)
async def test_user_tweets():
api = get_api()
mock_rep(api.user_tweets_raw, "raw_user_tweets", as_generator=True)
tweets = await gather(api.user_tweets(2244994945))
assert len(tweets) > 0
is_any_pinned = False
for doc in tweets:
check_tweet(doc)
is_any_pinned = is_any_pinned or doc.id in doc.user.pinnedIds
assert is_any_pinned, "at least one tweet should be pinned (or bad luck with data)"
async def test_user_tweets_and_replies():
api = get_api()
mock_rep(api.user_tweets_and_replies_raw, "raw_user_tweets_and_replies", as_generator=True)
tweets = await gather(api.user_tweets_and_replies(2244994945))
assert len(tweets) > 0
for doc in tweets:
check_tweet(doc)
async def test_raw_user_media():
api = get_api()
mock_rep(api.user_media_raw, "raw_user_media", as_generator=True)
tweets = await gather(api.user_media(2244994945))
assert len(tweets) > 0
for doc in tweets:
check_tweet(doc)
assert doc.media is not None
media_count = len(doc.media.photos) + len(doc.media.videos) + len(doc.media.animated)
assert media_count > 0, f"{doc.url} should have media"
async def test_list_timeline():
api = get_api()
mock_rep(api.list_timeline_raw, "raw_list_timeline", as_generator=True)
tweets = await gather(api.list_timeline(1494877848087187461))
assert len(tweets) > 0
users = []
for doc in tweets:
check_tweet(doc)
users.extend(collect_tweet_users(doc))
check_user_field_coverage(users)
async def test_list_members():
api = get_api()
mock_rep(api.list_members_raw, "raw_list_members", as_generator=True)
users = await gather(api.list_members(1494877848087187461))
assert len(users) > 0
for doc in users:
check_user(doc)
async def test_community_info():
api = get_api()
mock_rep(api.community_info_raw, "raw_community_info")
info = await api.community_info(1501272736215322629)
assert info is not None
assert isinstance(info.id, int)
assert info.name is not None
assert isinstance(info.memberCount, int)
async def test_community_members():
api = get_api()
mock_rep(api.community_members_raw, "raw_community_members", as_generator=True)
users = await gather(api.community_members(1501272736215322629))
assert len(users) > 0
for doc in users:
check_user(doc)
async def test_community_moderators():
api = get_api()
mock_rep(api.community_moderators_raw, "raw_community_moderators", as_generator=True)
users = await gather(api.community_moderators(1501272736215322629))
assert len(users) > 0
for doc in users:
check_user(doc)
async def test_community_tweets():
api = get_api()
mock_rep(api.community_tweets_raw, "raw_community_tweets", as_generator=True)
tweets = await gather(api.community_tweets(1501272736215322629))
assert len(tweets) > 0
for doc in tweets:
check_tweet(doc)
async def test_trends():
api = get_api()
mock_rep(api.trends_raw, "raw_trends", as_generator=True)
items = await gather(api.trends("sport"))
assert len(items) > 0
for doc in items:
check_trend(doc)
async def test_tweet_with_video():
api = get_api()
files = [
("manual_tweet_with_video_1.json", 1671508600538161153),
("manual_tweet_with_video_2.json", 1671753569412820992),
]
for file, twid in files:
mock_rep(api.tweet_details_raw, file)
doc = await api.tweet_details(twid)
assert doc is not None
check_tweet(doc)
async def test_issue_28():
api = get_api()
mock_rep(api.tweet_details_raw, "_issue_28_1")
doc = await api.tweet_details(1658409412799737856)
assert doc is not None
check_tweet(doc)
assert doc.id == 1658409412799737856
assert doc.user is not None
assert doc.retweetedTweet is not None
assert doc.retweetedTweet.viewCount is not None
assert doc.viewCount is not None # views should come from retweetedTweet
assert doc.viewCount == doc.retweetedTweet.viewCount
check_tweet(doc.retweetedTweet)
mock_rep(api.tweet_details_raw, "_issue_28_2")
doc = await api.tweet_details(1658421690001502208)
assert doc is not None
check_tweet(doc)
assert doc.id == 1658421690001502208
assert doc.viewCount is not None
assert doc.quotedTweet is not None
assert doc.quotedTweet.id != doc.id
check_tweet(doc.quotedTweet)
assert doc.quotedTweet.viewCount is not None
async def test_issue_42():
raw = fake_rep("_issue_42").json()
doc = parse_tweet(raw, 1665951747842641921)
assert doc is not None
assert doc.retweetedTweet is not None
assert doc.rawContent is not None
assert doc.retweetedTweet.rawContent is not None
assert doc.rawContent.endswith(doc.retweetedTweet.rawContent)
def test_retweet_not_duplicated():
"""The original tweet embedded inside a retweet must not also be yielded
as a standalone top-level item by parse_tweets."""
raw = fake_rep("_issue_42").json()
tweets = list(parse_tweets(raw))
rt_wrapper = next((t for t in tweets if t.id == 1665951747842641921), None)
assert rt_wrapper is not None, "RT wrapper tweet not found"
assert rt_wrapper.retweetedTweet is not None
original_id = rt_wrapper.retweetedTweet.id
assert all(t.id != original_id for t in tweets), (
f"retweetedTweet {original_id} leaked as a standalone top-level item"
)
async def test_issue_56():
raw = fake_rep("_issue_56").json()
doc = parse_tweet(raw, 1682072224013099008)
assert doc is not None
assert len({x.tcourl for x in doc.links}) == len(doc.links)
assert len(doc.links) == 5
async def test_issue_310():
api = get_api()
mock_rep(api.user_tweets_raw, "raw_user_tweets", as_generator=True)
tweets = await gather(api.user_tweets(2244994945))
top_level_ids = {x.id for x in tweets}
retweeted_ids = {x.retweetedTweet.id for x in tweets if x.retweetedTweet is not None}
leaked_ids = top_level_ids & retweeted_ids
assert retweeted_ids
assert not leaked_ids, (
f"top_level={len(top_level_ids)}, "
f"retweets={sum(x.retweetedTweet is not None for x in tweets)}, "
f"retweeted_children={len(retweeted_ids)}, "
f"leaked={len(leaked_ids)}"
)
def test_article_tweet():
raw = fake_rep("raw_user_tweets").json()
doc = parse_tweet(raw, 2079814622639469025)
assert doc is not None
assert doc.article is None
assert doc.retweetedTweet is not None
article = doc.retweetedTweet.article
assert isinstance(article, Article)
assert article.id == "QXJ0aWNsZUVudGl0eToyMDc5MTYyMDYxMTQyMzM1NDg4"
assert article.rest_id == "2079162061142335488"
assert (
article.title
== "Turn your X account into a programmable intelligence system using the X API and Grok Build"
)
assert article.preview_text.startswith("Your X account can now become")
assert article.modified_at_secs == 1784687550
assert article.first_published_at_secs == 1784687550
def test_article_rich_content():
with open(os.path.join(DATA_DIR, "article.json")) as fp:
raw = json.load(fp)
source = raw["data"]["threaded_conversation_with_injections_v2"]["instructions"][1]["entries"][
0
]["content"]["itemContent"]["tweet_results"]["result"]["article"]["article_results"]["result"]
doc = parse_tweet(raw, 2075503860689281453)
assert doc is not None
assert isinstance(doc.article, Article)
article = doc.article
assert article.title == source["title"]
assert article.summaryText == source["summary_text"]
assert article.isGrokSummaryEligible is True
assert article.coverMedia is not None
assert article.coverMedia.mediaId == source["cover_media"]["media_id"]
assert (
article.coverMedia.mediaInfo["original_img_url"]
== (source["cover_media"]["media_info"]["original_img_url"])
)
assert article.coverMedia.mediaInfo["original_img_width"] == 1024
assert article.coverMedia.mediaInfo["original_img_height"] == 410
assert article.mediaEntities == source["media_entities"]
assert article.contentState is not None
content = article.contentState
assert [x.key for x in content.blocks] == [x["key"] for x in source["content_state"]["blocks"]]
assert list(content.entityMap) == [str(x["key"]) for x in source["content_state"]["entityMap"]]
assert {x.type for x in content.blocks} == {
"unstyled",
"atomic",
"header-two",
"unordered-list-item",
}
assert any(x.data.get("urls") for x in content.blocks)
assert any(x.data.get("hashtags") for x in content.blocks)
assert any(x.inlineStyleRanges for x in content.blocks)
assert all(x.key in content.entityMap for block in content.blocks for x in block.entityRanges)
entities = [content.entityMap[x.key] for block in content.blocks for x in block.entityRanges]
assert {x.type for x in entities} >= {"MARKDOWN", "TWEMOJI", "DIVIDER"}
assert any(x.data.get("markdown", "").startswith("```bash") for x in entities)
assert any(x.data.get("url", "").endswith(".svg") for x in entities)
assert any(x.type == "DIVIDER" and x.data == {} for x in entities)
parsed = article.dict()
assert len(parsed["contentState"]["blocks"]) == len(source["content_state"]["blocks"])
assert parsed["contentState"]["entityMap"].keys() == content.entityMap.keys()
async def test_tweet_details_article(api_mock: API, monkeypatch):
with open(os.path.join(DATA_DIR, "article.json")) as fp:
raw = json.load(fp)
async def article_tweet_details_raw(twid: int, kv=None):
assert twid == 2075503860689281453
return raw
monkeypatch.setattr(api_mock, "tweet_details_raw", article_tweet_details_raw)
doc = await api_mock.tweet_details(2075503860689281453)
assert doc is not None
assert doc.article is not None
assert doc.article.contentState is not None
assert doc.article.coverMedia is not None
assert doc.article.summaryText is not None
async def test_issue_315():
"""Quoted tweets must not leak as standalone timeline items.
In raw_user_tweets the quoting tweets quote tweets by other users
(@milichab, @XFreeze, @SpaceXAI, @nicole_clash, @agno_three, @nikitabier).
X returns those quoted tweets embedded in the quoting tweet's payload and
they must not be yielded as top-level items too:
https://github.com/vladkens/twscrape/issues/315
"""
raw = fake_rep("raw_user_tweets").json()
tweets = list(parse_tweets(raw))
top_level_ids = {x.id_str for x in tweets}
# the fixture quotes 6 tweets by other users; none may leak top-level
other_user_quotes = {
x.quotedTweet.id_str
for x in tweets
if x.quotedTweet is not None and x.quotedTweet.user.username != x.user.username
}
assert len(other_user_quotes) == 6
assert not (other_user_quotes & top_level_ids), (
f"quoted tweets leaked as standalone items: {other_user_quotes & top_level_ids}"
)
# a self-quoted tweet X lists inside its own thread module entry remains a
# real result (5 items removed vs the pre-fix 25)
assert len(tweets) == 21
assert "2082640274845811115" in top_level_ids
@pytest.mark.parametrize(
"module",
["conversationthread", "list-conversation", "profile-grid", "tweetdetailrelatedtweets"],
)
def test_quoted_tweet_in_other_timeline_modules_is_standalone(module):
raw = fake_rep("raw_user_tweets").json()
quoted_id = "2082640274845811115"
entry = find_obj(raw, lambda x: x.get("entryId", "").endswith(f"-tweet-{quoted_id}"))
assert entry is not None
entry["entryId"] = entry["entryId"].replace("profile-conversation-", f"{module}-", 1)
assert quoted_id in {tweet.id_str for tweet in parse_tweets(raw)}
def _users_in_order(obj, res: list | None = None) -> list[dict]:
# User objects in the order get_typed_object visits them
res = [] if res is None else res
if isinstance(obj, dict):
if obj.get("__typename") == "User":
res.append(obj)
for v in obj.values():
_users_in_order(v, res)
elif isinstance(obj, list):
for v in obj:
_users_in_order(v, res)
return res
def _automated_by_label(user_id: str, screen_name: str) -> dict:
# A bot's "Automated by @operator" label embeds the operator as a partial User
mention = {"__typename": "User", "rest_id": user_id, "core": {"screen_name": screen_name}}
entity = {"ref": {"mention_results": {"result": mention}}}
return {"label": {"longDescription": {"entities": [entity]}}}
def test_partial_user_does_not_overwrite_full_user():
# https://github.com/vladkens/twscrape/issues/341
# Real UserTweets page: two retweets of @rodalies, then a retweet of the bot @rod11cat
# whose "Automated by @rodalies" label embeds @rodalies as a partial User.
raw = fake_rep("_issue_341").json()
assert to_old_rep(raw)["users"]["110680768"]["name"] == "Rodalies Catalunya"
tweets = {x.id: x for x in parse_tweets(raw)}
for twid in (2106637440312369380, 2106444684209893707):
rt = tweets[twid].retweetedTweet
assert rt is not None
assert rt.user.username == "rodalies"
def test_partial_only_user_keeps_user_lookup_single():
# The operator of a looked-up bot exists only as a partial User: it must stay
# unparseable, or parse_user sees two users and returns None.
expected = parse_user(fake_rep("raw_user_by_login").json())
assert expected is not None
raw = fake_rep("raw_user_by_login").json()
_users_in_order(raw)[-1]["affiliates_highlighted_label"] = _automated_by_label(
"123456789", "operator_account"
)
doc = parse_user(raw)
assert doc is not None
assert doc.id == expected.id
def test_nested_author_without_profile_keeps_outer_tweet():
# https://github.com/vladkens/twscrape/issues/342
# https://github.com/vladkens/twscrape/issues/343
# UserTweets page from raw_user_tweets, with nested authors replaced by the shapes X sent
# in production: UserUnavailable (#343) and a User stub without core (#342).
tweets = {x.id: x for x in parse_tweets(fake_rep("_issue_342_343").json())}
# retweet of an unavailable author: handle and name from the retweet's user_mentions
rt = tweets[2083305234688803178].retweetedTweet
assert rt is not None
assert rt.id == 2083289084567724316
assert rt.rawContent.startswith("Overhauled the 𝕏 Developer Console")
assert (rt.user.id, rt.user.username, rt.user.displayname) == (
2606432720,
"benjitaylor",
"Benji Taylor",
)
# unavailable author of a tweet quoted by a retweet: handle from quoted_status_permalink
rt = tweets[2082157691451920667].retweetedTweet
assert rt is not None
qt = rt.quotedTweet
assert qt is not None
assert qt.id == 2082134521370272218
assert (qt.user.id, qt.user.username) == (2867234365, "milichab")
# retweet of a stub author
rt = tweets[2079814622639469025].retweetedTweet
assert rt is not None
assert (rt.user.id, rt.user.username, rt.user.displayname) == (
1818311005698678784,
"XFreeze",
"X Freeze",
)
async def test_cards():
# Issues:
# - https://github.com/vladkens/twscrape/issues/72
# - https://github.com/vladkens/twscrape/issues/191
# Check SummaryCard
raw = fake_rep("card_summary").json()
doc = parse_tweet(raw, 1696922210588410217)
assert doc is not None
assert doc.card is not None
assert isinstance(doc.card, SummaryCard)
assert doc.card._type == "summary"
assert doc.card.title is not None
assert doc.card.description is not None
assert doc.card.url is not None
# Check PollCard
raw = fake_rep("card_poll").json()
doc = parse_tweet(raw, 1780666831310877100)
assert doc is not None
assert doc.card is not None
assert isinstance(doc.card, PollCard)
assert doc.card._type == "poll"
assert doc.card.finished is not None
assert doc.card.options is not None
assert len(doc.card.options) > 0
for x in doc.card.options:
assert x.label is not None
assert x.votesCount is not None
# Check PollCard with video (poll2choice_video)
raw = fake_rep("card_poll_video").json()
doc = parse_tweet(raw, 1114574134397165568)
assert doc is not None and doc.card is not None
assert isinstance(doc.card, PollCard)
assert doc.card._type == "poll"
assert len(doc.card.options) == 2
assert doc.card.finished is True
assert doc.card.videoUrl is not None
assert doc.card.durationSeconds == 24
assert doc.card.photo is not None
image_poll = fake_rep("card_poll").json()
card = find_obj(
image_poll,
lambda x: (
isinstance(x.get("legacy"), dict)
and str(x["legacy"].get("name", "")).startswith("poll")
),
)
assert card is not None
card["legacy"]["name"] = "1906814671912599552:poll_choice_images"
doc = parse_tweet(image_poll, 1780666831310877100)
assert doc is not None
assert isinstance(doc.card, PollCard)
# Check BrodcastCard
raw = fake_rep("card_broadcast").json()
doc = parse_tweet(raw, 1790441814857826439)
assert doc is not None and doc.card is not None
assert doc.card._type == "broadcast"
assert isinstance(doc.card, BroadcastCard)
assert doc.card.title is not None
assert doc.card.url is not None
assert doc.card.photo is not None
# Check AudiospaceCard
raw = fake_rep("card_audiospace").json()
doc = parse_tweet(raw, 1789054061729173804)
assert doc is not None and doc.card is not None
assert doc.card._type == "audiospace"
assert isinstance(doc.card, AudiospaceCard)
assert doc.card.url is not None
# Check MessageMeCard
raw = fake_rep("card_message_me").json()
doc = parse_tweet(raw, 1916063270131191983)
assert doc is not None and doc.card is not None
assert doc.card._type == "message_me"
assert isinstance(doc.card, MessageMeCard)
assert doc.card.url is not None
assert doc.card.cta is not None
assert doc.card.recipientId == "85741735"
# Check PeriscopeBroadcastCard
raw = fake_rep("card_periscope_broadcast").json()
doc = parse_tweet(raw, 822817044450013184)
assert doc is not None and doc.card is not None
assert doc.card._type == "periscope_broadcast"
assert isinstance(doc.card, PeriscopeBroadcastCard)
assert doc.card.title is not None
assert doc.card.url is not None
assert doc.card.state == "ENDED"
assert doc.card.broadcasterUsername == "womensmarch"
assert doc.card.thumbnailUrl is not None
# Check PromoVideoConvoCard
raw = fake_rep("card_promo_video_convo").json()
doc = parse_tweet(raw, 1090673433690685441)
assert doc is not None and doc.card is not None
assert doc.card._type == "promo_video_convo"
assert isinstance(doc.card, PromoVideoConvoCard)
assert doc.card.title == "Add your voice."
assert doc.card.thankYouText is not None
assert doc.card.videoUrl is not None
assert doc.card.durationSeconds == 5
assert doc.card.ctas == ["#BellLetsTalk"]
assert doc.card.photo is not None
# Check PromoImageConvoCard
raw = fake_rep("card_promo_image_convo").json()
doc = parse_tweet(raw, 2078218219689492480)
assert doc is not None and doc.card is not None
assert doc.card._type == "promo_image_convo"
assert isinstance(doc.card, PromoImageConvoCard)
assert doc.card.title is not None
assert doc.card.thankYouText is not None
assert doc.card.thankYouUrl is not None
assert doc.card.ctas == ["#onerufflecheddarlong"]
# main image, not the cover_promo_image ad cover (that one lives under /ad_img/)
assert doc.card.photo is not None
assert "/ad_img/" not in doc.card.photo.url
# Check LiveEventCard
raw = fake_rep("card_live_event").json()
doc = parse_tweet(raw, 1463203718153703425)
assert doc is not None and doc.card is not None
assert doc.card._type == "live_event"
assert isinstance(doc.card, LiveEventCard)
assert doc.card.title is not None
assert doc.card.url is not None
assert doc.card.eventId == "1461789549739143169"
assert doc.card.category == "Music"
assert doc.card.subtitle is not None
assert doc.card.photo is not None
# Check AppCard
raw = fake_rep("card_app").json()
doc = parse_tweet(raw, 1249486216266788865)
assert doc is not None and doc.card is not None
assert doc.card._type == "app"
assert isinstance(doc.card, AppCard)
assert doc.card.title == "The NBC App – Stream TV Shows"
assert doc.card.url is not None
assert doc.card.description is not None
assert doc.card.starRating is not None and 4 < doc.card.starRating < 5
assert doc.card.numRatings == 289838
assert doc.card.photo is not None
async def test_tweet_new_fields():
tweets = [
*parse_tweets(fake_rep("raw_search").json()),
*parse_tweets(fake_rep("raw_tweet_replies").json()),
]
assert len(tweets) > 0
for doc in tweets:
assert isinstance(doc.isQuoteStatus, bool)
assert isinstance(doc.isTranslatable, bool)
if doc.displayTextRange is not None:
assert isinstance(doc.displayTextRange, list)
assert len(doc.displayTextRange) == 2
assert all(isinstance(x, int) for x in doc.displayTextRange)
if doc.inReplyToScreenName is not None:
assert isinstance(doc.inReplyToScreenName, str)
assert len(doc.inReplyToScreenName) > 0
if doc.editControl is not None:
assert isinstance(doc.editControl, dict)
assert "edit_tweet_ids" in doc.editControl
assert isinstance(doc.editControl["edit_tweet_ids"], list)
assert "is_edit_eligible" in doc.editControl
assert isinstance(doc.editControl["is_edit_eligible"], bool)
assert doc.voiceInfo is None or isinstance(doc.voiceInfo, dict)
assert any(doc.isQuoteStatus for doc in tweets), "expected at least one quote tweet"
assert any(doc.inReplyToScreenName for doc in tweets), "expected at least one reply tweet"
assert any(doc.editControl is not None for doc in tweets), (
"expected editControl in at least one tweet"
)
assert any(doc.displayTextRange is not None for doc in tweets), (
"expected displayTextRange in at least one tweet"
)