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.
- {birdapi-0.0.2 → birdapi-0.0.3}/CLAUDE.md +1 -0
- {birdapi-0.0.2 → birdapi-0.0.3}/PKG-INFO +1 -1
- {birdapi-0.0.2 → birdapi-0.0.3}/pyproject.toml +1 -1
- {birdapi-0.0.2 → birdapi-0.0.3}/src/bird/__init__.py +12 -1
- {birdapi-0.0.2 → birdapi-0.0.3}/src/bird/_constants.py +1 -0
- {birdapi-0.0.2 → birdapi-0.0.3}/src/bird/_models.py +34 -0
- {birdapi-0.0.2 → birdapi-0.0.3}/src/bird/_utils.py +109 -2
- {birdapi-0.0.2 → birdapi-0.0.3}/src/bird/cli.py +142 -2
- {birdapi-0.0.2 → birdapi-0.0.3}/src/bird/client.py +66 -0
- {birdapi-0.0.2 → birdapi-0.0.3}/tests/test_utils.py +148 -0
- {birdapi-0.0.2 → birdapi-0.0.3}/.gitattributes +0 -0
- {birdapi-0.0.2 → birdapi-0.0.3}/.github/workflows/workflow.yml +0 -0
- {birdapi-0.0.2 → birdapi-0.0.3}/.gitignore +0 -0
- {birdapi-0.0.2 → birdapi-0.0.3}/LICENSE +0 -0
- {birdapi-0.0.2 → birdapi-0.0.3}/README.md +0 -0
- {birdapi-0.0.2 → birdapi-0.0.3}/src/bird/_config.py +0 -0
- {birdapi-0.0.2 → birdapi-0.0.3}/src/bird/_features.py +0 -0
- {birdapi-0.0.2 → birdapi-0.0.3}/src/bird/_query_ids.py +0 -0
- {birdapi-0.0.2 → birdapi-0.0.3}/tests/__init__.py +0 -0
- {birdapi-0.0.2 → birdapi-0.0.3}/tests/test_client.py +0 -0
|
@@ -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.
|
|
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
|
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
"""bird — X/Twitter GraphQL client library."""
|
|
2
2
|
|
|
3
3
|
from .client import TwitterClient
|
|
4
|
-
from ._models import
|
|
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(
|
|
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"] = {
|
|
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
|
|
File without changes
|