birdapi 0.0.2__tar.gz → 0.0.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -79,6 +79,7 @@ client.get_home_timeline(count) # → list[Tweet]
79
79
  client.get_home_latest_timeline(count) # → list[Tweet]
80
80
  client.get_current_user() # → User | None
81
81
  client.get_user_id_by_username(handle) # → User | None
82
+ client.get_user_profile(handle) # → UserProfile | None (full profile via UserByScreenName)
82
83
  client.get_following(user_id, count) # → (list[User], next_cursor)
83
84
  client.get_followers(user_id, count) # → (list[User], next_cursor)
84
85
  client.like/unlike/retweet/unretweet/bookmark/unbookmark(tweet_id) # → bool
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: birdapi
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Summary: CLI and library for X/Twitter GraphQL API (cookie auth, no API key required)
5
5
  Project-URL: Homepage, https://github.com/dvermaas/birdapi
6
6
  Project-URL: Repository, https://github.com/dvermaas/birdapi
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "birdapi"
7
- version = "0.0.2"
7
+ version = "0.0.3"
8
8
  description = "CLI and library for X/Twitter GraphQL API (cookie auth, no API key required)"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -1,12 +1,23 @@
1
1
  """bird — X/Twitter GraphQL client library."""
2
2
 
3
3
  from .client import TwitterClient
4
- from ._models import AboutProfile, ArticleMetadata, Author, MediaItem, NewsItem, Tweet, TwitterList, User
4
+ from ._models import (
5
+ AboutProfile,
6
+ ArticleMetadata,
7
+ Author,
8
+ MediaItem,
9
+ NewsItem,
10
+ Tweet,
11
+ TwitterList,
12
+ User,
13
+ UserProfile,
14
+ )
5
15
 
6
16
  __all__ = [
7
17
  "TwitterClient",
8
18
  "Tweet",
9
19
  "User",
20
+ "UserProfile",
10
21
  "Author",
11
22
  "MediaItem",
12
23
  "ArticleMetadata",
@@ -41,6 +41,7 @@ FALLBACK_QUERY_IDS: dict[str, str] = {
41
41
  "GenericTimelineById": "uGSr7alSjR9v6QJAIaqSKQ",
42
42
  "TrendHistory": "Sj4T-jSB9pr0Mxtsc1UKZQ",
43
43
  "AboutAccountQuery": "zs_jFPFT78rBpXv9Z3U2YQ",
44
+ "UserByScreenName": "681MIj51w00Aj6dY0GXnHw",
44
45
  }
45
46
 
46
47
  SETTINGS_SCREEN_NAME_RE = re.compile(r'"screen_name":"([^"]+)"')
@@ -8,6 +8,7 @@ from typing import Any, Optional
8
8
  class Author:
9
9
  username: str
10
10
  name: str
11
+ profile_image_url: Optional[str] = None
11
12
 
12
13
 
13
14
  @dataclass
@@ -36,6 +37,7 @@ class Tweet:
36
37
  reply_count: Optional[int] = None
37
38
  retweet_count: Optional[int] = None
38
39
  like_count: Optional[int] = None
40
+ view_count: Optional[int] = None
39
41
  conversation_id: Optional[str] = None
40
42
  in_reply_to_status_id: Optional[str] = None
41
43
  author_id: Optional[str] = None
@@ -58,6 +60,38 @@ class User:
58
60
  created_at: Optional[str] = None
59
61
 
60
62
 
63
+ @dataclass
64
+ class UserProfile:
65
+ """Full profile information from the UserByScreenName endpoint."""
66
+
67
+ id: str
68
+ username: str
69
+ name: str
70
+ description: Optional[str] = None
71
+ location: Optional[str] = None
72
+ website: Optional[str] = None
73
+ created_at: Optional[str] = None
74
+ followers_count: Optional[int] = None
75
+ following_count: Optional[int] = None
76
+ tweet_count: Optional[int] = None
77
+ media_count: Optional[int] = None
78
+ listed_count: Optional[int] = None
79
+ likes_count: Optional[int] = None
80
+ is_blue_verified: Optional[bool] = None
81
+ is_verified: Optional[bool] = None
82
+ verified_type: Optional[str] = None
83
+ is_identity_verified: Optional[bool] = None
84
+ verified_since: Optional[str] = None
85
+ profile_image_url: Optional[str] = None
86
+ profile_banner_url: Optional[str] = None
87
+ is_protected: Optional[bool] = None
88
+ can_dm: Optional[bool] = None
89
+ pinned_tweet_ids: Optional[list[str]] = None
90
+ professional_type: Optional[str] = None
91
+ professional_category: Optional[str] = None
92
+ _raw: Optional[Any] = field(default=None, repr=False)
93
+
94
+
61
95
  @dataclass
62
96
  class TwitterList:
63
97
  id: str
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import re
6
- from datetime import datetime
6
+ from datetime import datetime, timezone
7
7
  from typing import Any, Optional
8
8
 
9
9
  from ._models import (
@@ -12,6 +12,7 @@ from ._models import (
12
12
  MediaItem,
13
13
  Tweet,
14
14
  User,
15
+ UserProfile,
15
16
  )
16
17
 
17
18
 
@@ -360,6 +361,24 @@ def _unwrap_tweet_result(result: Optional[dict]) -> Optional[dict]:
360
361
  return result.get("tweet") or result
361
362
 
362
363
 
364
+ def _extract_view_count(result: dict, legacy: dict) -> Optional[int]:
365
+ """Pull the impression count from views.count (or legacy ext_views)."""
366
+ raw = (result.get("views") or {}).get("count")
367
+ if raw is None:
368
+ raw = (legacy.get("ext_views") or {}).get("count")
369
+ try:
370
+ return int(raw) if raw is not None else None
371
+ except (TypeError, ValueError):
372
+ return None
373
+
374
+
375
+ def _extract_user_avatar(user_result: dict) -> Optional[str]:
376
+ return (
377
+ (user_result.get("avatar") or {}).get("image_url")
378
+ or (user_result.get("legacy") or {}).get("profile_image_url_https")
379
+ )
380
+
381
+
363
382
  def map_tweet_result(
364
383
  result: Optional[dict],
365
384
  quote_depth: int = 1,
@@ -397,11 +416,16 @@ def map_tweet_result(
397
416
  tweet = Tweet(
398
417
  id=result["rest_id"],
399
418
  text=text,
400
- author=Author(username=username, name=name or username),
419
+ author=Author(
420
+ username=username,
421
+ name=name or username,
422
+ profile_image_url=_extract_user_avatar(user_result),
423
+ ),
401
424
  created_at=legacy.get("created_at"),
402
425
  reply_count=legacy.get("reply_count"),
403
426
  retweet_count=legacy.get("retweet_count"),
404
427
  like_count=legacy.get("favorite_count"),
428
+ view_count=_extract_view_count(result, legacy),
405
429
  conversation_id=legacy.get("conversation_id_str"),
406
430
  in_reply_to_status_id=legacy.get("in_reply_to_status_id_str") or None,
407
431
  author_id=user_id,
@@ -478,6 +502,89 @@ def find_tweet_in_instructions(
478
502
  return None
479
503
 
480
504
 
505
+ def map_user_profile_result(
506
+ result: Optional[dict],
507
+ include_raw: bool = False,
508
+ ) -> Optional[UserProfile]:
509
+ """Map a UserByScreenName ``user.result`` payload to a UserProfile.
510
+
511
+ Handles both the new schema (core/avatar/privacy/location/profile_bio)
512
+ and the legacy field layout.
513
+ """
514
+ if not result:
515
+ return None
516
+ if result.get("__typename") == "UserWithVisibilityResults" and result.get("user"):
517
+ result = result["user"]
518
+ legacy = result.get("legacy") or {}
519
+ core = result.get("core") or {}
520
+ username = core.get("screen_name") or legacy.get("screen_name")
521
+ if not result.get("rest_id") or not username:
522
+ return None
523
+
524
+ website: Optional[str] = None
525
+ for u in ((legacy.get("entities") or {}).get("url") or {}).get("urls") or []:
526
+ if u.get("expanded_url"):
527
+ website = u["expanded_url"]
528
+ break
529
+ if not website:
530
+ website = legacy.get("url")
531
+
532
+ verification = result.get("verification") or {}
533
+ verification_info = result.get("verification_info") or {}
534
+ verified_since: Optional[str] = None
535
+ msec = (verification_info.get("reason") or {}).get("verified_since_msec")
536
+ try:
537
+ if msec is not None:
538
+ verified_since = datetime.fromtimestamp(
539
+ int(msec) / 1000, tz=timezone.utc
540
+ ).isoformat()
541
+ except (TypeError, ValueError, OSError, OverflowError):
542
+ verified_since = None
543
+
544
+ privacy = result.get("privacy") or {}
545
+ is_protected = privacy.get("protected")
546
+ if is_protected is None:
547
+ is_protected = legacy.get("protected")
548
+
549
+ professional = result.get("professional") or {}
550
+ categories = professional.get("category") or []
551
+ professional_category = next(
552
+ (c.get("name") for c in categories if c.get("name")), None
553
+ )
554
+
555
+ profile = UserProfile(
556
+ id=result["rest_id"],
557
+ username=username,
558
+ name=core.get("name") or legacy.get("name") or username,
559
+ description=(result.get("profile_bio") or {}).get("description")
560
+ or legacy.get("description"),
561
+ location=(result.get("location") or {}).get("location") or legacy.get("location"),
562
+ website=website,
563
+ created_at=core.get("created_at") or legacy.get("created_at"),
564
+ followers_count=legacy.get("followers_count"),
565
+ following_count=legacy.get("friends_count"),
566
+ tweet_count=legacy.get("statuses_count"),
567
+ media_count=legacy.get("media_count"),
568
+ listed_count=legacy.get("listed_count"),
569
+ likes_count=legacy.get("favourites_count"),
570
+ is_blue_verified=result.get("is_blue_verified"),
571
+ is_verified=verification.get("verified"),
572
+ verified_type=verification.get("verified_type"),
573
+ is_identity_verified=verification_info.get("is_identity_verified"),
574
+ verified_since=verified_since,
575
+ profile_image_url=_extract_user_avatar(result),
576
+ profile_banner_url=legacy.get("profile_banner_url"),
577
+ is_protected=is_protected,
578
+ can_dm=(result.get("dm_permissions") or {}).get("can_dm"),
579
+ pinned_tweet_ids=legacy.get("pinned_tweet_ids_str") or None,
580
+ professional_type=professional.get("professional_type"),
581
+ professional_category=professional_category,
582
+ )
583
+ if include_raw:
584
+ profile._raw = result
585
+ return profile
586
+
587
+
481
588
  def parse_users_from_instructions(instructions: Optional[list]) -> list[User]:
482
589
  users: list[User] = []
483
590
  for instruction in instructions or []:
@@ -171,6 +171,11 @@ def _format_tweet(tweet, plain: bool = False, show_stats: bool = False) -> str:
171
171
  lines.append(f"url: {url}")
172
172
  else:
173
173
  lines.append(f"\U0001f517 {url}")
174
+ if tweet.author.profile_image_url:
175
+ if plain:
176
+ lines.append(f"avatar: {tweet.author.profile_image_url}")
177
+ else:
178
+ lines.append(f"\U0001f464 {tweet.author.profile_image_url}")
174
179
 
175
180
  # Engagement stats (shown for single-tweet read, not list views)
176
181
  if show_stats and not plain:
@@ -181,9 +186,16 @@ def _format_tweet(tweet, plain: bool = False, show_stats: bool = False) -> str:
181
186
  parts.append(f"\U0001f501 {tweet.retweet_count}")
182
187
  if tweet.reply_count is not None:
183
188
  parts.append(f"\U0001f4ac {tweet.reply_count}")
189
+ if tweet.view_count is not None:
190
+ parts.append(f"\U0001f441\ufe0f {tweet.view_count}")
184
191
  if parts:
185
192
  lines.append(" ".join(parts))
186
193
  else:
194
+ if tweet.view_count is not None:
195
+ if plain:
196
+ lines.append(f"views: {tweet.view_count}")
197
+ else:
198
+ lines.append(f"\U0001f441\ufe0f {tweet.view_count} views")
187
199
  lines.append(_SEPARATOR)
188
200
 
189
201
  return "\n".join(lines)
@@ -213,11 +225,16 @@ def _tweet_to_dict(tweet, include_raw: bool = False) -> dict:
213
225
  "replyCount": tweet.reply_count,
214
226
  "retweetCount": tweet.retweet_count,
215
227
  "likeCount": tweet.like_count,
228
+ "viewCount": tweet.view_count,
216
229
  "conversationId": tweet.conversation_id,
217
230
  }
218
231
  if tweet.in_reply_to_status_id:
219
232
  d["inReplyToStatusId"] = tweet.in_reply_to_status_id
220
- d["author"] = {"username": tweet.author.username, "name": tweet.author.name}
233
+ d["author"] = {
234
+ "username": tweet.author.username,
235
+ "name": tweet.author.name,
236
+ "profileImageUrl": tweet.author.profile_image_url,
237
+ }
221
238
  d["authorId"] = tweet.author_id
222
239
  if tweet.quoted_tweet:
223
240
  d["quotedTweet"] = _tweet_to_dict(tweet.quoted_tweet, include_raw=include_raw)
@@ -796,9 +813,132 @@ def trending(ctx, count, as_json):
796
813
 
797
814
 
798
815
  # ---------------------------------------------------------------------------
799
- # about / whoami / check
816
+ # user / about / whoami / check
800
817
  # ---------------------------------------------------------------------------
801
818
 
819
+ def _profile_to_dict(p, include_raw: bool = False) -> dict:
820
+ d: dict = {
821
+ "id": p.id,
822
+ "username": p.username,
823
+ "name": p.name,
824
+ "description": p.description,
825
+ "location": p.location,
826
+ "website": p.website,
827
+ "createdAt": p.created_at,
828
+ "followersCount": p.followers_count,
829
+ "followingCount": p.following_count,
830
+ "tweetCount": p.tweet_count,
831
+ "mediaCount": p.media_count,
832
+ "listedCount": p.listed_count,
833
+ "likesCount": p.likes_count,
834
+ "isBlueVerified": p.is_blue_verified,
835
+ "isVerified": p.is_verified,
836
+ "verifiedType": p.verified_type,
837
+ "isIdentityVerified": p.is_identity_verified,
838
+ "verifiedSince": p.verified_since,
839
+ "profileImageUrl": p.profile_image_url,
840
+ "profileBannerUrl": p.profile_banner_url,
841
+ "isProtected": p.is_protected,
842
+ "canDm": p.can_dm,
843
+ "pinnedTweetIds": p.pinned_tweet_ids,
844
+ "professionalType": p.professional_type,
845
+ "professionalCategory": p.professional_category,
846
+ }
847
+ if include_raw and p._raw is not None:
848
+ d["_raw"] = p._raw
849
+ return d
850
+
851
+
852
+ def _format_profile(p, plain: bool = False) -> str:
853
+ def line(emoji: str, label: str, value) -> str:
854
+ return f"{label}: {value}" if plain else f"{emoji} {value}"
855
+
856
+ lines = [f"@{p.username} ({p.name})"]
857
+ if p.description:
858
+ lines.append(_unescape(p.description))
859
+ if p.location:
860
+ lines.append(line("\U0001f4cd", "location", p.location))
861
+ if p.website:
862
+ lines.append(line("\U0001f517", "website", p.website))
863
+ if p.created_at:
864
+ lines.append(line("\U0001f4c5", "joined", p.created_at))
865
+
866
+ counts = []
867
+ if p.followers_count is not None:
868
+ counts.append(f"{p.followers_count:,} followers")
869
+ if p.following_count is not None:
870
+ counts.append(f"{p.following_count:,} following")
871
+ if p.tweet_count is not None:
872
+ counts.append(f"{p.tweet_count:,} tweets")
873
+ if p.media_count is not None:
874
+ counts.append(f"{p.media_count:,} media")
875
+ if p.likes_count is not None:
876
+ counts.append(f"{p.likes_count:,} likes")
877
+ if p.listed_count is not None:
878
+ counts.append(f"{p.listed_count:,} listed")
879
+ if counts:
880
+ lines.append(line("\U0001f465", "stats", " · ".join(counts)))
881
+
882
+ badges = []
883
+ if p.verified_type:
884
+ badges.append(f"{p.verified_type} verified")
885
+ elif p.is_verified:
886
+ badges.append("verified")
887
+ if p.is_blue_verified:
888
+ badges.append("blue check")
889
+ if p.is_identity_verified:
890
+ badges.append("identity verified")
891
+ if p.is_protected:
892
+ badges.append("protected")
893
+ if badges:
894
+ lines.append(line("✅", "verified", ", ".join(badges)))
895
+ if p.verified_since:
896
+ lines.append(line("\U0001f4ce", "verified since", p.verified_since))
897
+ if p.professional_type:
898
+ prof = p.professional_type
899
+ if p.professional_category:
900
+ prof += f" ({p.professional_category})"
901
+ lines.append(line("\U0001f4bc", "professional", prof))
902
+ if p.can_dm is not None:
903
+ dm = "open" if p.can_dm else "closed"
904
+ lines.append(f"dms: {dm}" if plain else f"\U0001f4e9 DMs {dm}")
905
+ if p.pinned_tweet_ids:
906
+ lines.append(line("\U0001f4cc", "pinned", ", ".join(p.pinned_tweet_ids)))
907
+ if p.profile_image_url:
908
+ lines.append(f"avatar: {p.profile_image_url}" if plain
909
+ else f"\U0001f464 {p.profile_image_url}")
910
+ if p.profile_banner_url:
911
+ lines.append(f"banner: {p.profile_banner_url}" if plain
912
+ else f"\U0001f5bc\ufe0f {p.profile_banner_url}")
913
+ lines.append(f"id: {p.id}")
914
+ return "\n".join(lines)
915
+
916
+
917
+ @main.command("user")
918
+ @click.argument("handle")
919
+ @click.option("--json", "as_json", is_flag=True)
920
+ @click.option("--json-full", "json_full", is_flag=True,
921
+ help="Include raw API response in _raw field.")
922
+ @click.pass_context
923
+ def user_profile(ctx, handle, as_json, json_full):
924
+ """Show full profile information for a user."""
925
+ as_json = as_json or json_full or ctx.obj.get("as_json")
926
+ plain = ctx.obj.get("plain", False)
927
+ norm = normalize_handle(handle)
928
+ if not norm:
929
+ click.echo(f"Invalid handle: {handle!r}", err=True)
930
+ sys.exit(1)
931
+ with _client(ctx) as client:
932
+ profile = client.get_user_profile(norm, include_raw=json_full)
933
+ if not profile:
934
+ click.echo(f"User @{norm} not found.", err=True)
935
+ sys.exit(1)
936
+ if as_json:
937
+ click.echo(json.dumps(_profile_to_dict(profile, include_raw=json_full),
938
+ ensure_ascii=False, indent=2))
939
+ else:
940
+ click.echo(_format_profile(profile, plain=plain))
941
+
802
942
  @main.command()
803
943
  @click.argument("handle")
804
944
  @click.option("--json", "as_json", is_flag=True)
@@ -44,10 +44,12 @@ from ._features import (
44
44
  )
45
45
  from ._models import (
46
46
  AboutProfile,
47
+ Author,
47
48
  NewsItem,
48
49
  Tweet,
49
50
  TwitterList,
50
51
  User,
52
+ UserProfile,
51
53
  )
52
54
  from ._query_ids import query_id_store
53
55
  from ._utils import (
@@ -56,6 +58,7 @@ from ._utils import (
56
58
  extract_cursor_from_instructions,
57
59
  find_tweet_in_instructions,
58
60
  map_tweet_result,
61
+ map_user_profile_result,
59
62
  normalize_handle,
60
63
  parse_tweet_datetime,
61
64
  parse_tweets_from_instructions,
@@ -483,6 +486,69 @@ class TwitterClient:
483
486
  pass
484
487
  return None
485
488
 
489
+ def get_user_profile(
490
+ self, username: str, include_raw: bool = False
491
+ ) -> Optional[UserProfile]:
492
+ """Fetch full profile information for a user via UserByScreenName."""
493
+ handle = normalize_handle(username)
494
+ if not handle:
495
+ return None
496
+ qids = list(dict.fromkeys([
497
+ self._get_query_id("UserByScreenName"),
498
+ "681MIj51w00Aj6dY0GXnHw",
499
+ "xc8f1g7BYqr6VTzTbvNlGw",
500
+ ]))
501
+ variables = {"screen_name": handle, "withGrokTranslatedBio": True}
502
+ features = {
503
+ "hidden_profile_subscriptions_enabled": True,
504
+ "profile_label_improvements_pcf_label_in_post_enabled": True,
505
+ "responsive_web_profile_redirect_enabled": False,
506
+ "rweb_tipjar_consumption_enabled": False,
507
+ "verified_phone_label_enabled": False,
508
+ "subscriptions_verification_info_is_identity_verified_enabled": True,
509
+ "subscriptions_verification_info_verified_since_enabled": True,
510
+ "highlights_tweets_tab_ui_enabled": True,
511
+ "responsive_web_twitter_article_notes_tab_enabled": True,
512
+ "subscriptions_feature_can_gift_premium": True,
513
+ "creator_subscriptions_tweet_preview_api_enabled": True,
514
+ "responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
515
+ "responsive_web_graphql_timeline_navigation_enabled": True,
516
+ }
517
+ params = httpx.QueryParams(
518
+ variables=json.dumps(variables),
519
+ features=json.dumps(features),
520
+ fieldToggles=json.dumps({"withPayments": False, "withAuxiliaryUserLabels": True}),
521
+ )
522
+ had_404 = False
523
+ for qid in qids:
524
+ try:
525
+ r = self._get(f"{TWITTER_API_BASE}/{qid}/UserByScreenName?{params}")
526
+ if r.status_code == 404:
527
+ had_404 = True
528
+ continue
529
+ if not r.is_success:
530
+ continue
531
+ data = r.json()
532
+ result = (data.get("data") or {}).get("user", {}).get("result") or {}
533
+ if result.get("__typename") == "UserUnavailable":
534
+ return None
535
+ profile = map_user_profile_result(result, include_raw)
536
+ if profile:
537
+ return profile
538
+ except Exception:
539
+ pass
540
+ if had_404:
541
+ self._refresh_query_ids()
542
+ try:
543
+ qid = self._get_query_id("UserByScreenName")
544
+ r = self._get(f"{TWITTER_API_BASE}/{qid}/UserByScreenName?{params}")
545
+ if r.is_success:
546
+ result = (r.json().get("data") or {}).get("user", {}).get("result") or {}
547
+ return map_user_profile_result(result, include_raw)
548
+ except Exception:
549
+ pass
550
+ return None
551
+
486
552
  # ------------------------------------------------------------------
487
553
  # Tweet detail / thread / replies
488
554
  # ------------------------------------------------------------------
@@ -9,6 +9,7 @@ from bird._utils import (
9
9
  extract_list_id,
10
10
  extract_tweet_id,
11
11
  map_tweet_result,
12
+ map_user_profile_result,
12
13
  normalize_handle,
13
14
  parse_tweet_datetime,
14
15
  parse_tweets_from_instructions,
@@ -153,6 +154,42 @@ def test_map_tweet_result_missing_username():
153
154
  raw["core"]["user_results"]["result"]["legacy"].pop("screen_name")
154
155
  assert map_tweet_result(raw) is None
155
156
 
157
+ def test_map_tweet_result_view_count():
158
+ raw = _make_raw_tweet()
159
+ raw["views"] = {"count": "12345", "state": "EnabledWithCount"}
160
+ tweet = map_tweet_result(raw)
161
+ assert tweet.view_count == 12345
162
+
163
+ def test_map_tweet_result_view_count_legacy_ext_views():
164
+ raw = _make_raw_tweet()
165
+ raw["legacy"]["ext_views"] = {"count": "777"}
166
+ tweet = map_tweet_result(raw)
167
+ assert tweet.view_count == 777
168
+
169
+ def test_map_tweet_result_view_count_missing_or_invalid():
170
+ raw = _make_raw_tweet()
171
+ assert map_tweet_result(raw).view_count is None
172
+ raw["views"] = {"state": "Enabled"} # no count
173
+ assert map_tweet_result(raw).view_count is None
174
+ raw["views"] = {"count": "not-a-number"}
175
+ assert map_tweet_result(raw).view_count is None
176
+
177
+ def test_map_tweet_result_author_avatar_new_schema():
178
+ raw = _make_raw_tweet()
179
+ raw["core"]["user_results"]["result"]["avatar"] = {
180
+ "image_url": "https://pbs.twimg.com/profile_images/1/x_normal.jpg"
181
+ }
182
+ tweet = map_tweet_result(raw)
183
+ assert tweet.author.profile_image_url == "https://pbs.twimg.com/profile_images/1/x_normal.jpg"
184
+
185
+ def test_map_tweet_result_author_avatar_legacy():
186
+ raw = _make_raw_tweet()
187
+ raw["core"]["user_results"]["result"]["legacy"]["profile_image_url_https"] = (
188
+ "https://pbs.twimg.com/profile_images/2/y_normal.jpg"
189
+ )
190
+ tweet = map_tweet_result(raw)
191
+ assert tweet.author.profile_image_url == "https://pbs.twimg.com/profile_images/2/y_normal.jpg"
192
+
156
193
  def test_map_tweet_result_quoted_tweet():
157
194
  inner = _make_raw_tweet("2", "Quoted", "bob", "Bob", "u2")
158
195
  outer = _make_raw_tweet("1", "Outer")
@@ -265,3 +302,114 @@ def test_extract_cursor():
265
302
  def test_extract_cursor_missing():
266
303
  assert extract_cursor_from_instructions([]) is None
267
304
  assert extract_cursor_from_instructions(None) is None
305
+
306
+
307
+ # ---------------------------------------------------------------------------
308
+ # map_user_profile_result
309
+ # ---------------------------------------------------------------------------
310
+
311
+ def _make_raw_user_profile():
312
+ """Trimmed UserByScreenName user.result payload (new schema)."""
313
+ return {
314
+ "__typename": "User",
315
+ "rest_id": "1120633726478823425",
316
+ "avatar": {"image_url": "https://pbs.twimg.com/profile_images/1/a_normal.jpg"},
317
+ "core": {
318
+ "created_at": "Tue Apr 23 10:21:15 +0000 2019",
319
+ "name": "Volodymyr Zelenskyy",
320
+ "screen_name": "ZelenskyyUa",
321
+ },
322
+ "dm_permissions": {"can_dm": False},
323
+ "is_blue_verified": True,
324
+ "legacy": {
325
+ "description": "President of Ukraine",
326
+ "entities": {
327
+ "url": {
328
+ "urls": [
329
+ {
330
+ "display_url": "president.gov.ua",
331
+ "expanded_url": "https://www.president.gov.ua",
332
+ "url": "https://t.co/ctVL0atMBQ",
333
+ }
334
+ ]
335
+ }
336
+ },
337
+ "favourites_count": 214,
338
+ "followers_count": 8515293,
339
+ "friends_count": 1,
340
+ "listed_count": 18781,
341
+ "media_count": 7501,
342
+ "profile_banner_url": "https://pbs.twimg.com/profile_banners/1/1692773060",
343
+ "statuses_count": 15669,
344
+ "url": "https://t.co/ctVL0atMBQ",
345
+ },
346
+ "location": {"location": "Україна"},
347
+ "privacy": {"protected": False},
348
+ "profile_bio": {"description": "President of Ukraine"},
349
+ "verification": {"verified": False, "verified_type": "Government"},
350
+ "verification_info": {
351
+ "is_identity_verified": False,
352
+ "reason": {"verified_since_msec": "1559215763761"},
353
+ },
354
+ }
355
+
356
+
357
+ def test_map_user_profile_result_full():
358
+ p = map_user_profile_result(_make_raw_user_profile())
359
+ assert p is not None
360
+ assert p.id == "1120633726478823425"
361
+ assert p.username == "ZelenskyyUa"
362
+ assert p.name == "Volodymyr Zelenskyy"
363
+ assert p.description == "President of Ukraine"
364
+ assert p.location == "Україна"
365
+ assert p.website == "https://www.president.gov.ua"
366
+ assert p.created_at == "Tue Apr 23 10:21:15 +0000 2019"
367
+ assert p.followers_count == 8515293
368
+ assert p.following_count == 1
369
+ assert p.tweet_count == 15669
370
+ assert p.media_count == 7501
371
+ assert p.listed_count == 18781
372
+ assert p.likes_count == 214
373
+ assert p.is_blue_verified is True
374
+ assert p.is_verified is False
375
+ assert p.verified_type == "Government"
376
+ assert p.is_identity_verified is False
377
+ assert p.verified_since == "2019-05-30T11:29:23.761000+00:00"
378
+ assert p.profile_image_url == "https://pbs.twimg.com/profile_images/1/a_normal.jpg"
379
+ assert p.profile_banner_url == "https://pbs.twimg.com/profile_banners/1/1692773060"
380
+ assert p.is_protected is False
381
+ assert p.can_dm is False
382
+ assert p._raw is None
383
+
384
+
385
+ def test_map_user_profile_result_include_raw():
386
+ raw = _make_raw_user_profile()
387
+ p = map_user_profile_result(raw, include_raw=True)
388
+ assert p._raw is raw
389
+
390
+
391
+ def test_map_user_profile_result_legacy_schema():
392
+ # Older payloads carry name/screen_name under legacy instead of core.
393
+ p = map_user_profile_result({
394
+ "rest_id": "42",
395
+ "legacy": {
396
+ "screen_name": "alice",
397
+ "name": "Alice",
398
+ "description": "hi",
399
+ "followers_count": 5,
400
+ "profile_image_url_https": "https://pbs.twimg.com/profile_images/2/b_normal.jpg",
401
+ "protected": True,
402
+ },
403
+ })
404
+ assert p is not None
405
+ assert p.username == "alice"
406
+ assert p.name == "Alice"
407
+ assert p.followers_count == 5
408
+ assert p.profile_image_url == "https://pbs.twimg.com/profile_images/2/b_normal.jpg"
409
+ assert p.is_protected is True
410
+
411
+
412
+ def test_map_user_profile_result_invalid():
413
+ assert map_user_profile_result(None) is None
414
+ assert map_user_profile_result({}) is None
415
+ assert map_user_profile_result({"rest_id": "1"}) is None # no screen_name
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes