birdapi 0.0.1__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.1
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.1"
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,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import re
6
+ from datetime import datetime, timezone
6
7
  from typing import Any, Optional
7
8
 
8
9
  from ._models import (
@@ -11,6 +12,7 @@ from ._models import (
11
12
  MediaItem,
12
13
  Tweet,
13
14
  User,
15
+ UserProfile,
14
16
  )
15
17
 
16
18
 
@@ -20,6 +22,19 @@ from ._models import (
20
22
 
21
23
  _HANDLE_RE = re.compile(r"^[A-Za-z0-9_]{1,15}$")
22
24
 
25
+ # X timestamp format, e.g. "Sun Jun 07 23:11:05 +0000 2026"
26
+ _TWEET_TIME_FMT = "%a %b %d %H:%M:%S %z %Y"
27
+
28
+
29
+ def parse_tweet_datetime(created_at: Optional[str]) -> Optional[datetime]:
30
+ """Parse a tweet ``created_at`` string into an aware datetime, or None."""
31
+ if not created_at:
32
+ return None
33
+ try:
34
+ return datetime.strptime(created_at, _TWEET_TIME_FMT)
35
+ except (ValueError, TypeError):
36
+ return None
37
+
23
38
 
24
39
  def normalize_handle(raw: Optional[str]) -> Optional[str]:
25
40
  if not raw:
@@ -346,11 +361,33 @@ def _unwrap_tweet_result(result: Optional[dict]) -> Optional[dict]:
346
361
  return result.get("tweet") or result
347
362
 
348
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
+
349
382
  def map_tweet_result(
350
383
  result: Optional[dict],
351
384
  quote_depth: int = 1,
352
385
  include_raw: bool = False,
353
386
  ) -> Optional[Tweet]:
387
+ if not result:
388
+ return None
389
+ # Unwrap TweetWithVisibilityResults for callers that pass the raw result directly.
390
+ result = _unwrap_tweet_result(result)
354
391
  if not result:
355
392
  return None
356
393
  user_result = (result.get("core") or {}).get("user_results", {}).get("result") or {}
@@ -379,11 +416,16 @@ def map_tweet_result(
379
416
  tweet = Tweet(
380
417
  id=result["rest_id"],
381
418
  text=text,
382
- 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
+ ),
383
424
  created_at=legacy.get("created_at"),
384
425
  reply_count=legacy.get("reply_count"),
385
426
  retweet_count=legacy.get("retweet_count"),
386
427
  like_count=legacy.get("favorite_count"),
428
+ view_count=_extract_view_count(result, legacy),
387
429
  conversation_id=legacy.get("conversation_id_str"),
388
430
  in_reply_to_status_id=legacy.get("in_reply_to_status_id_str") or None,
389
431
  author_id=user_id,
@@ -401,6 +443,9 @@ def _collect_tweet_results_from_entry(entry: dict) -> list[dict]:
401
443
  content = entry.get("content") or {}
402
444
 
403
445
  def push(r: Optional[dict]) -> None:
446
+ # Visibility-gated tweets arrive wrapped as TweetWithVisibilityResults,
447
+ # with rest_id nested under .tweet — unwrap before the rest_id check.
448
+ r = _unwrap_tweet_result(r)
404
449
  if r and r.get("rest_id"):
405
450
  results.append(r)
406
451
 
@@ -451,11 +496,95 @@ def find_tweet_in_instructions(
451
496
  result = (entry.get("content") or {}).get("itemContent", {}).get(
452
497
  "tweet_results", {}
453
498
  ).get("result")
499
+ result = _unwrap_tweet_result(result)
454
500
  if result and result.get("rest_id") == tweet_id:
455
501
  return result
456
502
  return None
457
503
 
458
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
+
459
588
  def parse_users_from_instructions(instructions: Optional[list]) -> list[User]:
460
589
  users: list[User] = []
461
590
  for instruction in instructions or []: