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.
- {birdapi-0.0.1 → birdapi-0.0.3}/CLAUDE.md +1 -0
- {birdapi-0.0.1 → birdapi-0.0.3}/PKG-INFO +1 -1
- {birdapi-0.0.1 → birdapi-0.0.3}/pyproject.toml +1 -1
- {birdapi-0.0.1 → birdapi-0.0.3}/src/bird/__init__.py +12 -1
- {birdapi-0.0.1 → birdapi-0.0.3}/src/bird/_constants.py +1 -0
- {birdapi-0.0.1 → birdapi-0.0.3}/src/bird/_models.py +34 -0
- {birdapi-0.0.1 → birdapi-0.0.3}/src/bird/_utils.py +130 -1
- {birdapi-0.0.1 → birdapi-0.0.3}/src/bird/cli.py +412 -81
- {birdapi-0.0.1 → birdapi-0.0.3}/src/bird/client.py +185 -29
- birdapi-0.0.3/tests/test_client.py +109 -0
- birdapi-0.0.3/tests/test_utils.py +415 -0
- birdapi-0.0.1/tests/test_utils.py +0 -222
- {birdapi-0.0.1 → birdapi-0.0.3}/.gitattributes +0 -0
- {birdapi-0.0.1 → birdapi-0.0.3}/.github/workflows/workflow.yml +0 -0
- {birdapi-0.0.1 → birdapi-0.0.3}/.gitignore +0 -0
- {birdapi-0.0.1 → birdapi-0.0.3}/LICENSE +0 -0
- {birdapi-0.0.1 → birdapi-0.0.3}/README.md +0 -0
- {birdapi-0.0.1 → birdapi-0.0.3}/src/bird/_config.py +0 -0
- {birdapi-0.0.1 → birdapi-0.0.3}/src/bird/_features.py +0 -0
- {birdapi-0.0.1 → birdapi-0.0.3}/src/bird/_query_ids.py +0 -0
- {birdapi-0.0.1 → birdapi-0.0.3}/tests/__init__.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,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(
|
|
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 []:
|