birdapi 0.0.1__py3-none-any.whl
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.
- bird/__init__.py +16 -0
- bird/_config.py +63 -0
- bird/_constants.py +48 -0
- bird/_features.py +256 -0
- bird/_models.py +92 -0
- bird/_query_ids.py +211 -0
- bird/_utils.py +491 -0
- bird/cli.py +769 -0
- bird/client.py +1702 -0
- birdapi-0.0.1.dist-info/METADATA +207 -0
- birdapi-0.0.1.dist-info/RECORD +14 -0
- birdapi-0.0.1.dist-info/WHEEL +4 -0
- birdapi-0.0.1.dist-info/entry_points.txt +2 -0
- birdapi-0.0.1.dist-info/licenses/LICENSE +21 -0
bird/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""bird — X/Twitter GraphQL client library."""
|
|
2
|
+
|
|
3
|
+
from .client import TwitterClient
|
|
4
|
+
from ._models import AboutProfile, ArticleMetadata, Author, MediaItem, NewsItem, Tweet, TwitterList, User
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"TwitterClient",
|
|
8
|
+
"Tweet",
|
|
9
|
+
"User",
|
|
10
|
+
"Author",
|
|
11
|
+
"MediaItem",
|
|
12
|
+
"ArticleMetadata",
|
|
13
|
+
"TwitterList",
|
|
14
|
+
"AboutProfile",
|
|
15
|
+
"NewsItem",
|
|
16
|
+
]
|
bird/_config.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Credential storage for bird — ~/.config/bird/credentials.json."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_CREDS_PATH = Path.home() / ".config" / "bird" / "credentials.json"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _creds_path() -> Path:
|
|
14
|
+
return _CREDS_PATH
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_credentials() -> dict[str, str]:
|
|
18
|
+
"""Return saved credentials, or an empty dict if none exist."""
|
|
19
|
+
try:
|
|
20
|
+
return json.loads(_creds_path().read_text())
|
|
21
|
+
except Exception:
|
|
22
|
+
return {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def save_credentials(auth_token: str, ct0: str) -> Path:
|
|
26
|
+
"""Write credentials to disk and return the file path."""
|
|
27
|
+
path = _creds_path()
|
|
28
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
path.write_text(json.dumps({"auth_token": auth_token, "ct0": ct0}, indent=2) + "\n")
|
|
30
|
+
# Restrict permissions on non-Windows (best effort)
|
|
31
|
+
try:
|
|
32
|
+
path.chmod(0o600)
|
|
33
|
+
except Exception:
|
|
34
|
+
pass
|
|
35
|
+
return path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve_credentials(
|
|
39
|
+
auth_token: Optional[str] = None,
|
|
40
|
+
ct0: Optional[str] = None,
|
|
41
|
+
) -> tuple[Optional[str], Optional[str]]:
|
|
42
|
+
"""Return (auth_token, ct0) from the first source that has both values.
|
|
43
|
+
|
|
44
|
+
Priority: explicit args → env vars → saved credentials file.
|
|
45
|
+
"""
|
|
46
|
+
import os
|
|
47
|
+
|
|
48
|
+
tok = (
|
|
49
|
+
auth_token
|
|
50
|
+
or os.environ.get("AUTH_TOKEN")
|
|
51
|
+
or os.environ.get("TWITTER_AUTH_TOKEN")
|
|
52
|
+
)
|
|
53
|
+
csrf = (
|
|
54
|
+
ct0
|
|
55
|
+
or os.environ.get("CT0")
|
|
56
|
+
or os.environ.get("TWITTER_CT0")
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if tok and csrf:
|
|
60
|
+
return tok, csrf
|
|
61
|
+
|
|
62
|
+
saved = load_credentials()
|
|
63
|
+
return saved.get("auth_token") or tok, saved.get("ct0") or csrf
|
bird/_constants.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
TWITTER_API_BASE = "https://x.com/i/api/graphql"
|
|
4
|
+
TWITTER_GRAPHQL_POST_URL = "https://x.com/i/api/graphql"
|
|
5
|
+
TWITTER_STATUS_UPDATE_URL = "https://x.com/i/api/1.1/statuses/update.json"
|
|
6
|
+
|
|
7
|
+
# Public bearer token used by the X/Twitter web client
|
|
8
|
+
BEARER_TOKEN = (
|
|
9
|
+
"AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs"
|
|
10
|
+
"%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
# Fallback query IDs — refreshed at runtime from x.com bundles; these keep
|
|
14
|
+
# the client usable if the cache is missing or stale.
|
|
15
|
+
FALLBACK_QUERY_IDS: dict[str, str] = {
|
|
16
|
+
"CreateTweet": "TAJw1rBsjAtdNgTdlo2oeg",
|
|
17
|
+
"CreateRetweet": "ojPdsZsimiJrUGLR1sjUtA",
|
|
18
|
+
"DeleteRetweet": "iQtK4dl5hBmXewYZuEOKVw",
|
|
19
|
+
"CreateFriendship": "8h9JVdV8dlSyqyRDJEPCsA",
|
|
20
|
+
"DestroyFriendship": "ppXWuagMNXgvzx6WoXBW0Q",
|
|
21
|
+
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
|
|
22
|
+
"UnfavoriteTweet": "ZYKSe-w7KEslx3JhSIk5LA",
|
|
23
|
+
"CreateBookmark": "aoDbu3RHznuiSkQ9aNM67Q",
|
|
24
|
+
"DeleteBookmark": "Wlmlj2-xzyS1GN3a6cj-mQ",
|
|
25
|
+
"TweetDetail": "97JF30KziU00483E_8elBA",
|
|
26
|
+
"SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ",
|
|
27
|
+
"UserArticlesTweets": "8zBy9h4L90aDL02RsBcCFg",
|
|
28
|
+
"UserTweets": "Wms1GvIiHXAPBaCr9KblaA",
|
|
29
|
+
"Bookmarks": "RV1g3b8n_SGOHwkqKYSCFw",
|
|
30
|
+
"Following": "BEkNpEt5pNETESoqMsTEGA",
|
|
31
|
+
"Followers": "kuFUYP9eV1FPoEy4N-pi7w",
|
|
32
|
+
"Likes": "JR2gceKucIKcVNB_9JkhsA",
|
|
33
|
+
"BookmarkFolderTimeline": "KJIQpsvxrTfRIlbaRIySHQ",
|
|
34
|
+
"ListOwnerships": "wQcOSjSQ8NtgxIwvYl1lMg",
|
|
35
|
+
"ListMemberships": "BlEXXdARdSeL_0KyKHHvvg",
|
|
36
|
+
"ListLatestTweetsTimeline": "2TemLyqrMpTeAmysdbnVqw",
|
|
37
|
+
"HomeTimeline": "edseUwk9sP5Phz__9TIRnA",
|
|
38
|
+
"HomeLatestTimeline": "iOEZpOdfekFsxSlPQCQtPg",
|
|
39
|
+
"ExploreSidebar": "lpSN4M6qpimkF4nRFPE3nQ",
|
|
40
|
+
"ExplorePage": "kheAINB_4pzRDqkzG3K-ng",
|
|
41
|
+
"GenericTimelineById": "uGSr7alSjR9v6QJAIaqSKQ",
|
|
42
|
+
"TrendHistory": "Sj4T-jSB9pr0Mxtsc1UKZQ",
|
|
43
|
+
"AboutAccountQuery": "zs_jFPFT78rBpXv9Z3U2YQ",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
SETTINGS_SCREEN_NAME_RE = re.compile(r'"screen_name":"([^"]+)"')
|
|
47
|
+
SETTINGS_USER_ID_RE = re.compile(r'"user_id"\s*:\s*"(\d+)"')
|
|
48
|
+
SETTINGS_NAME_RE = re.compile(r'"name":"([^"\\]*(?:\\.[^"\\]*)*)"')
|
bird/_features.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""GraphQL feature flag payloads, mirroring the X/Twitter web client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _article_features() -> dict:
|
|
7
|
+
return {
|
|
8
|
+
"rweb_video_screen_enabled": True,
|
|
9
|
+
"profile_label_improvements_pcf_label_in_post_enabled": True,
|
|
10
|
+
"responsive_web_profile_redirect_enabled": True,
|
|
11
|
+
"rweb_tipjar_consumption_enabled": True,
|
|
12
|
+
"verified_phone_label_enabled": False,
|
|
13
|
+
"creator_subscriptions_tweet_preview_api_enabled": True,
|
|
14
|
+
"responsive_web_graphql_timeline_navigation_enabled": True,
|
|
15
|
+
"responsive_web_graphql_exclude_directive_enabled": True,
|
|
16
|
+
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
|
|
17
|
+
"premium_content_api_read_enabled": False,
|
|
18
|
+
"communities_web_enable_tweet_community_results_fetch": True,
|
|
19
|
+
"c9s_tweet_anatomy_moderator_badge_enabled": True,
|
|
20
|
+
"responsive_web_grok_analyze_button_fetch_trends_enabled": False,
|
|
21
|
+
"responsive_web_grok_analyze_post_followups_enabled": False,
|
|
22
|
+
"responsive_web_grok_annotations_enabled": False,
|
|
23
|
+
"responsive_web_jetfuel_frame": True,
|
|
24
|
+
"post_ctas_fetch_enabled": True,
|
|
25
|
+
"responsive_web_grok_share_attachment_enabled": True,
|
|
26
|
+
"articles_preview_enabled": True,
|
|
27
|
+
"responsive_web_edit_tweet_api_enabled": True,
|
|
28
|
+
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": True,
|
|
29
|
+
"view_counts_everywhere_api_enabled": True,
|
|
30
|
+
"longform_notetweets_consumption_enabled": True,
|
|
31
|
+
"responsive_web_twitter_article_tweet_consumption_enabled": True,
|
|
32
|
+
"tweet_awards_web_tipping_enabled": False,
|
|
33
|
+
"responsive_web_grok_show_grok_translated_post": False,
|
|
34
|
+
"responsive_web_grok_analysis_button_from_backend": True,
|
|
35
|
+
"creator_subscriptions_quote_tweet_preview_enabled": False,
|
|
36
|
+
"freedom_of_speech_not_reach_fetch_enabled": True,
|
|
37
|
+
"standardized_nudges_misinfo": True,
|
|
38
|
+
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": True,
|
|
39
|
+
"longform_notetweets_rich_text_read_enabled": True,
|
|
40
|
+
"longform_notetweets_inline_media_enabled": True,
|
|
41
|
+
"responsive_web_grok_image_annotation_enabled": True,
|
|
42
|
+
"responsive_web_grok_imagine_annotation_enabled": True,
|
|
43
|
+
"responsive_web_grok_community_note_auto_translation_is_enabled": False,
|
|
44
|
+
"responsive_web_enhance_cards_enabled": False,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def tweet_detail_features() -> dict:
|
|
49
|
+
return {
|
|
50
|
+
**_article_features(),
|
|
51
|
+
"responsive_web_graphql_exclude_directive_enabled": True,
|
|
52
|
+
"communities_web_enable_tweet_community_results_fetch": True,
|
|
53
|
+
"responsive_web_twitter_article_plain_text_enabled": True,
|
|
54
|
+
"responsive_web_twitter_article_seed_tweet_detail_enabled": True,
|
|
55
|
+
"responsive_web_twitter_article_seed_tweet_summary_enabled": True,
|
|
56
|
+
"longform_notetweets_rich_text_read_enabled": True,
|
|
57
|
+
"longform_notetweets_inline_media_enabled": True,
|
|
58
|
+
"responsive_web_edit_tweet_api_enabled": True,
|
|
59
|
+
"tweet_awards_web_tipping_enabled": False,
|
|
60
|
+
"creator_subscriptions_quote_tweet_preview_enabled": False,
|
|
61
|
+
"verified_phone_label_enabled": False,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def article_field_toggles() -> dict:
|
|
66
|
+
return {
|
|
67
|
+
"withPayments": False,
|
|
68
|
+
"withAuxiliaryUserLabels": False,
|
|
69
|
+
"withArticleRichContentState": True,
|
|
70
|
+
"withArticlePlainText": True,
|
|
71
|
+
"withGrokAnalyze": False,
|
|
72
|
+
"withDisallowedReplyControls": False,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def search_features() -> dict:
|
|
77
|
+
return {
|
|
78
|
+
**_article_features(),
|
|
79
|
+
"rweb_video_timestamps_enabled": True,
|
|
80
|
+
"responsive_web_enhance_cards_enabled": False,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def tweet_create_features() -> dict:
|
|
85
|
+
return {
|
|
86
|
+
"rweb_video_screen_enabled": True,
|
|
87
|
+
"creator_subscriptions_tweet_preview_api_enabled": True,
|
|
88
|
+
"premium_content_api_read_enabled": False,
|
|
89
|
+
"communities_web_enable_tweet_community_results_fetch": True,
|
|
90
|
+
"c9s_tweet_anatomy_moderator_badge_enabled": True,
|
|
91
|
+
"responsive_web_grok_analyze_button_fetch_trends_enabled": False,
|
|
92
|
+
"responsive_web_grok_analyze_post_followups_enabled": False,
|
|
93
|
+
"responsive_web_grok_annotations_enabled": False,
|
|
94
|
+
"responsive_web_jetfuel_frame": True,
|
|
95
|
+
"post_ctas_fetch_enabled": True,
|
|
96
|
+
"responsive_web_grok_share_attachment_enabled": True,
|
|
97
|
+
"responsive_web_edit_tweet_api_enabled": True,
|
|
98
|
+
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": True,
|
|
99
|
+
"view_counts_everywhere_api_enabled": True,
|
|
100
|
+
"longform_notetweets_consumption_enabled": True,
|
|
101
|
+
"responsive_web_twitter_article_tweet_consumption_enabled": True,
|
|
102
|
+
"tweet_awards_web_tipping_enabled": False,
|
|
103
|
+
"responsive_web_grok_show_grok_translated_post": False,
|
|
104
|
+
"responsive_web_grok_analysis_button_from_backend": True,
|
|
105
|
+
"creator_subscriptions_quote_tweet_preview_enabled": False,
|
|
106
|
+
"longform_notetweets_rich_text_read_enabled": True,
|
|
107
|
+
"longform_notetweets_inline_media_enabled": True,
|
|
108
|
+
"profile_label_improvements_pcf_label_in_post_enabled": True,
|
|
109
|
+
"responsive_web_profile_redirect_enabled": False,
|
|
110
|
+
"rweb_tipjar_consumption_enabled": True,
|
|
111
|
+
"verified_phone_label_enabled": False,
|
|
112
|
+
"articles_preview_enabled": True,
|
|
113
|
+
"responsive_web_grok_community_note_auto_translation_is_enabled": False,
|
|
114
|
+
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
|
|
115
|
+
"freedom_of_speech_not_reach_fetch_enabled": True,
|
|
116
|
+
"standardized_nudges_misinfo": True,
|
|
117
|
+
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": True,
|
|
118
|
+
"responsive_web_grok_image_annotation_enabled": True,
|
|
119
|
+
"responsive_web_grok_imagine_annotation_enabled": True,
|
|
120
|
+
"responsive_web_graphql_timeline_navigation_enabled": True,
|
|
121
|
+
"responsive_web_enhance_cards_enabled": False,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _timeline_features() -> dict:
|
|
126
|
+
return {
|
|
127
|
+
**search_features(),
|
|
128
|
+
"blue_business_profile_image_shape_enabled": True,
|
|
129
|
+
"responsive_web_text_conversations_enabled": False,
|
|
130
|
+
"tweetypie_unmention_optimization_enabled": True,
|
|
131
|
+
"vibe_api_enabled": True,
|
|
132
|
+
"responsive_web_twitter_blue_verified_badge_is_enabled": True,
|
|
133
|
+
"interactive_text_enabled": True,
|
|
134
|
+
"longform_notetweets_richtext_consumption_enabled": True,
|
|
135
|
+
"responsive_web_media_download_video_enabled": False,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def bookmarks_features() -> dict:
|
|
140
|
+
return {
|
|
141
|
+
**_timeline_features(),
|
|
142
|
+
"graphql_timeline_v2_bookmark_timeline": True,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def likes_features() -> dict:
|
|
147
|
+
return _timeline_features()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def lists_features() -> dict:
|
|
151
|
+
return {
|
|
152
|
+
**_article_features(),
|
|
153
|
+
"blue_business_profile_image_shape_enabled": False,
|
|
154
|
+
"responsive_web_text_conversations_enabled": False,
|
|
155
|
+
"tweetypie_unmention_optimization_enabled": True,
|
|
156
|
+
"vibe_api_enabled": False,
|
|
157
|
+
"interactive_text_enabled": False,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def home_timeline_features() -> dict:
|
|
162
|
+
return _timeline_features()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def user_tweets_features() -> dict:
|
|
166
|
+
return {
|
|
167
|
+
"rweb_video_screen_enabled": False,
|
|
168
|
+
"profile_label_improvements_pcf_label_in_post_enabled": True,
|
|
169
|
+
"responsive_web_profile_redirect_enabled": False,
|
|
170
|
+
"rweb_tipjar_consumption_enabled": True,
|
|
171
|
+
"verified_phone_label_enabled": False,
|
|
172
|
+
"creator_subscriptions_tweet_preview_api_enabled": True,
|
|
173
|
+
"responsive_web_graphql_timeline_navigation_enabled": True,
|
|
174
|
+
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
|
|
175
|
+
"premium_content_api_read_enabled": False,
|
|
176
|
+
"communities_web_enable_tweet_community_results_fetch": True,
|
|
177
|
+
"c9s_tweet_anatomy_moderator_badge_enabled": True,
|
|
178
|
+
"responsive_web_grok_analyze_button_fetch_trends_enabled": False,
|
|
179
|
+
"responsive_web_grok_analyze_post_followups_enabled": True,
|
|
180
|
+
"responsive_web_jetfuel_frame": True,
|
|
181
|
+
"post_ctas_fetch_enabled": True,
|
|
182
|
+
"responsive_web_grok_share_attachment_enabled": True,
|
|
183
|
+
"responsive_web_grok_annotations_enabled": False,
|
|
184
|
+
"articles_preview_enabled": True,
|
|
185
|
+
"responsive_web_edit_tweet_api_enabled": True,
|
|
186
|
+
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": True,
|
|
187
|
+
"view_counts_everywhere_api_enabled": True,
|
|
188
|
+
"longform_notetweets_consumption_enabled": True,
|
|
189
|
+
"responsive_web_twitter_article_tweet_consumption_enabled": True,
|
|
190
|
+
"tweet_awards_web_tipping_enabled": False,
|
|
191
|
+
"responsive_web_grok_show_grok_translated_post": True,
|
|
192
|
+
"responsive_web_grok_analysis_button_from_backend": True,
|
|
193
|
+
"creator_subscriptions_quote_tweet_preview_enabled": False,
|
|
194
|
+
"freedom_of_speech_not_reach_fetch_enabled": True,
|
|
195
|
+
"standardized_nudges_misinfo": True,
|
|
196
|
+
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": True,
|
|
197
|
+
"longform_notetweets_rich_text_read_enabled": True,
|
|
198
|
+
"longform_notetweets_inline_media_enabled": True,
|
|
199
|
+
"responsive_web_grok_image_annotation_enabled": True,
|
|
200
|
+
"responsive_web_grok_imagine_annotation_enabled": True,
|
|
201
|
+
"responsive_web_grok_community_note_auto_translation_is_enabled": False,
|
|
202
|
+
"responsive_web_enhance_cards_enabled": False,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def following_features() -> dict:
|
|
207
|
+
return {
|
|
208
|
+
"rweb_video_screen_enabled": True,
|
|
209
|
+
"profile_label_improvements_pcf_label_in_post_enabled": False,
|
|
210
|
+
"responsive_web_profile_redirect_enabled": True,
|
|
211
|
+
"rweb_tipjar_consumption_enabled": True,
|
|
212
|
+
"verified_phone_label_enabled": False,
|
|
213
|
+
"creator_subscriptions_tweet_preview_api_enabled": True,
|
|
214
|
+
"responsive_web_graphql_timeline_navigation_enabled": True,
|
|
215
|
+
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
|
|
216
|
+
"premium_content_api_read_enabled": True,
|
|
217
|
+
"communities_web_enable_tweet_community_results_fetch": True,
|
|
218
|
+
"c9s_tweet_anatomy_moderator_badge_enabled": True,
|
|
219
|
+
"responsive_web_grok_analyze_button_fetch_trends_enabled": False,
|
|
220
|
+
"responsive_web_grok_analyze_post_followups_enabled": False,
|
|
221
|
+
"responsive_web_grok_annotations_enabled": False,
|
|
222
|
+
"responsive_web_jetfuel_frame": False,
|
|
223
|
+
"post_ctas_fetch_enabled": True,
|
|
224
|
+
"responsive_web_grok_share_attachment_enabled": False,
|
|
225
|
+
"articles_preview_enabled": True,
|
|
226
|
+
"responsive_web_edit_tweet_api_enabled": True,
|
|
227
|
+
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": True,
|
|
228
|
+
"view_counts_everywhere_api_enabled": True,
|
|
229
|
+
"longform_notetweets_consumption_enabled": True,
|
|
230
|
+
"responsive_web_twitter_article_tweet_consumption_enabled": True,
|
|
231
|
+
"tweet_awards_web_tipping_enabled": True,
|
|
232
|
+
"responsive_web_grok_show_grok_translated_post": False,
|
|
233
|
+
"responsive_web_grok_analysis_button_from_backend": False,
|
|
234
|
+
"creator_subscriptions_quote_tweet_preview_enabled": False,
|
|
235
|
+
"freedom_of_speech_not_reach_fetch_enabled": True,
|
|
236
|
+
"standardized_nudges_misinfo": True,
|
|
237
|
+
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": True,
|
|
238
|
+
"longform_notetweets_rich_text_read_enabled": True,
|
|
239
|
+
"longform_notetweets_inline_media_enabled": True,
|
|
240
|
+
"responsive_web_grok_image_annotation_enabled": False,
|
|
241
|
+
"responsive_web_grok_imagine_annotation_enabled": False,
|
|
242
|
+
"responsive_web_grok_community_note_auto_translation_is_enabled": False,
|
|
243
|
+
"responsive_web_enhance_cards_enabled": False,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def explore_features() -> dict:
|
|
248
|
+
return {
|
|
249
|
+
**_article_features(),
|
|
250
|
+
"responsive_web_grok_analyze_button_fetch_trends_enabled": True,
|
|
251
|
+
"responsive_web_grok_analyze_post_followups_enabled": True,
|
|
252
|
+
"responsive_web_grok_annotations_enabled": True,
|
|
253
|
+
"responsive_web_grok_show_grok_translated_post": True,
|
|
254
|
+
"responsive_web_grok_community_note_auto_translation_is_enabled": True,
|
|
255
|
+
"rweb_video_timestamps_enabled": True,
|
|
256
|
+
}
|
bird/_models.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Author:
|
|
9
|
+
username: str
|
|
10
|
+
name: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class MediaItem:
|
|
15
|
+
type: str # "photo", "video", "animated_gif"
|
|
16
|
+
url: str
|
|
17
|
+
width: Optional[int] = None
|
|
18
|
+
height: Optional[int] = None
|
|
19
|
+
preview_url: Optional[str] = None
|
|
20
|
+
video_url: Optional[str] = None
|
|
21
|
+
duration_ms: Optional[int] = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ArticleMetadata:
|
|
26
|
+
title: str
|
|
27
|
+
preview_text: Optional[str] = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Tweet:
|
|
32
|
+
id: str
|
|
33
|
+
text: str
|
|
34
|
+
author: Author
|
|
35
|
+
created_at: Optional[str] = None
|
|
36
|
+
reply_count: Optional[int] = None
|
|
37
|
+
retweet_count: Optional[int] = None
|
|
38
|
+
like_count: Optional[int] = None
|
|
39
|
+
conversation_id: Optional[str] = None
|
|
40
|
+
in_reply_to_status_id: Optional[str] = None
|
|
41
|
+
author_id: Optional[str] = None
|
|
42
|
+
quoted_tweet: Optional[Tweet] = None
|
|
43
|
+
media: Optional[list[MediaItem]] = None
|
|
44
|
+
article: Optional[ArticleMetadata] = None
|
|
45
|
+
_raw: Optional[Any] = field(default=None, repr=False)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class User:
|
|
50
|
+
id: str
|
|
51
|
+
username: str
|
|
52
|
+
name: str
|
|
53
|
+
description: Optional[str] = None
|
|
54
|
+
followers_count: Optional[int] = None
|
|
55
|
+
following_count: Optional[int] = None
|
|
56
|
+
is_blue_verified: Optional[bool] = None
|
|
57
|
+
profile_image_url: Optional[str] = None
|
|
58
|
+
created_at: Optional[str] = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class TwitterList:
|
|
63
|
+
id: str
|
|
64
|
+
name: str
|
|
65
|
+
description: Optional[str] = None
|
|
66
|
+
member_count: Optional[int] = None
|
|
67
|
+
subscriber_count: Optional[int] = None
|
|
68
|
+
is_private: bool = False
|
|
69
|
+
created_at: Optional[str] = None
|
|
70
|
+
owner: Optional[Author] = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class AboutProfile:
|
|
75
|
+
account_based_in: Optional[str] = None
|
|
76
|
+
source: Optional[str] = None
|
|
77
|
+
created_country_accurate: Optional[str] = None
|
|
78
|
+
location_accurate: Optional[str] = None
|
|
79
|
+
learn_more_url: Optional[str] = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class NewsItem:
|
|
84
|
+
id: str
|
|
85
|
+
headline: str
|
|
86
|
+
category: Optional[str] = None
|
|
87
|
+
time_ago: Optional[str] = None
|
|
88
|
+
post_count: Optional[int] = None
|
|
89
|
+
description: Optional[str] = None
|
|
90
|
+
url: Optional[str] = None
|
|
91
|
+
tweets: Optional[list[Tweet]] = None
|
|
92
|
+
_raw: Optional[Any] = field(default=None, repr=False)
|
bird/_query_ids.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Runtime query ID store — fetches and caches X/Twitter GraphQL query IDs.
|
|
2
|
+
|
|
3
|
+
X rotates these IDs frequently; this module scrapes them from the public
|
|
4
|
+
x.com JavaScript bundles so the client stays functional without manual updates.
|
|
5
|
+
Cache lives at ~/.config/bird/query-ids-cache.json (24 h TTL by default).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from ._constants import FALLBACK_QUERY_IDS
|
|
20
|
+
|
|
21
|
+
_DEFAULT_TTL = 24 * 60 * 60 # seconds
|
|
22
|
+
_CACHE_FILENAME = "query-ids-cache.json"
|
|
23
|
+
_DISCOVERY_PAGES = [
|
|
24
|
+
"https://x.com/?lang=en",
|
|
25
|
+
"https://x.com/explore",
|
|
26
|
+
"https://x.com/notifications",
|
|
27
|
+
"https://x.com/settings/profile",
|
|
28
|
+
]
|
|
29
|
+
_BUNDLE_URL_RE = re.compile(
|
|
30
|
+
r"https://abs\.twimg\.com/responsive-web/client-web(?:-legacy)?/[A-Za-z0-9.\-]+\.js"
|
|
31
|
+
)
|
|
32
|
+
_QUERY_ID_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
|
|
33
|
+
_OPERATION_PATTERNS: list[tuple[re.Pattern, int, int]] = [
|
|
34
|
+
# (pattern, operation_group, query_id_group)
|
|
35
|
+
(re.compile(r'e\.exports=\{queryId\s*:\s*["\']([^"\']+)["\']\s*,\s*operationName\s*:\s*["\']([^"\']+)["\']'), 2, 1),
|
|
36
|
+
(re.compile(r'e\.exports=\{operationName\s*:\s*["\']([^"\']+)["\']\s*,\s*queryId\s*:\s*["\']([^"\']+)["\']'), 1, 2),
|
|
37
|
+
(re.compile(r'operationName\s*[:=]\s*["\']([^"\']+)["\'].{0,4000}?queryId\s*[:=]\s*["\']([^"\']+)["\']', re.DOTALL), 1, 2),
|
|
38
|
+
(re.compile(r'queryId\s*[:=]\s*["\']([^"\']+)["\'].{0,4000}?operationName\s*[:=]\s*["\']([^"\']+)["\']', re.DOTALL), 2, 1),
|
|
39
|
+
]
|
|
40
|
+
_FETCH_HEADERS = {
|
|
41
|
+
"User-Agent": (
|
|
42
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
43
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
44
|
+
"Chrome/129.0.0.0 Safari/537.36"
|
|
45
|
+
),
|
|
46
|
+
"Accept": "text/html,application/json;q=0.9,*/*;q=0.8",
|
|
47
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _default_cache_path() -> Path:
|
|
52
|
+
import os
|
|
53
|
+
override = os.environ.get("BIRD_QUERY_IDS_CACHE", "").strip()
|
|
54
|
+
if override:
|
|
55
|
+
return Path(override)
|
|
56
|
+
return Path.home() / ".config" / "bird" / _CACHE_FILENAME
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _load_cache(path: Path) -> Optional[dict]:
|
|
60
|
+
try:
|
|
61
|
+
data = json.loads(path.read_text())
|
|
62
|
+
except Exception:
|
|
63
|
+
return None
|
|
64
|
+
if not isinstance(data, dict):
|
|
65
|
+
return None
|
|
66
|
+
for key in ("fetchedAt", "ttl", "ids"):
|
|
67
|
+
if key not in data:
|
|
68
|
+
return None
|
|
69
|
+
if not isinstance(data["ids"], dict):
|
|
70
|
+
return None
|
|
71
|
+
return data
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _save_cache(path: Path, snapshot: dict) -> None:
|
|
75
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
path.write_text(json.dumps(snapshot, indent=2) + "\n")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _discover_bundles(client: httpx.Client) -> list[str]:
|
|
80
|
+
bundles: set[str] = set()
|
|
81
|
+
for page in _DISCOVERY_PAGES:
|
|
82
|
+
try:
|
|
83
|
+
r = client.get(page, headers=_FETCH_HEADERS, timeout=20)
|
|
84
|
+
if r.is_success:
|
|
85
|
+
bundles.update(_BUNDLE_URL_RE.findall(r.text))
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
if not bundles:
|
|
89
|
+
raise RuntimeError("No X/Twitter client bundles discovered; layout may have changed.")
|
|
90
|
+
return list(bundles)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _extract_operations(js: str, targets: set[str], discovered: dict[str, str]) -> None:
|
|
94
|
+
for pattern, op_group, qid_group in _OPERATION_PATTERNS:
|
|
95
|
+
for m in pattern.finditer(js):
|
|
96
|
+
op_name = m.group(op_group)
|
|
97
|
+
query_id = m.group(qid_group)
|
|
98
|
+
if not op_name or not query_id:
|
|
99
|
+
continue
|
|
100
|
+
if op_name not in targets or op_name in discovered:
|
|
101
|
+
continue
|
|
102
|
+
if not _QUERY_ID_RE.match(query_id):
|
|
103
|
+
continue
|
|
104
|
+
discovered[op_name] = query_id
|
|
105
|
+
if len(discovered) == len(targets):
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _fetch_and_extract(
|
|
110
|
+
client: httpx.Client, bundle_urls: list[str], targets: set[str]
|
|
111
|
+
) -> dict[str, str]:
|
|
112
|
+
discovered: dict[str, str] = {}
|
|
113
|
+
CONCURRENCY = 6
|
|
114
|
+
|
|
115
|
+
def fetch_one(url: str) -> Optional[str]:
|
|
116
|
+
try:
|
|
117
|
+
r = client.get(url, headers=_FETCH_HEADERS, timeout=30)
|
|
118
|
+
return r.text if r.is_success else None
|
|
119
|
+
except Exception:
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
for i in range(0, len(bundle_urls), CONCURRENCY):
|
|
123
|
+
if len(discovered) == len(targets):
|
|
124
|
+
break
|
|
125
|
+
chunk = bundle_urls[i : i + CONCURRENCY]
|
|
126
|
+
with ThreadPoolExecutor(max_workers=CONCURRENCY) as ex:
|
|
127
|
+
futures = {ex.submit(fetch_one, url): url for url in chunk}
|
|
128
|
+
for fut in as_completed(futures):
|
|
129
|
+
js = fut.result()
|
|
130
|
+
if js:
|
|
131
|
+
_extract_operations(js, targets, discovered)
|
|
132
|
+
if len(discovered) == len(targets):
|
|
133
|
+
break
|
|
134
|
+
return discovered
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class QueryIdStore:
|
|
138
|
+
"""Thread-safe, disk-backed cache for X/Twitter GraphQL query IDs."""
|
|
139
|
+
|
|
140
|
+
def __init__(
|
|
141
|
+
self,
|
|
142
|
+
cache_path: Optional[Path] = None,
|
|
143
|
+
ttl: int = _DEFAULT_TTL,
|
|
144
|
+
) -> None:
|
|
145
|
+
self._cache_path = cache_path or _default_cache_path()
|
|
146
|
+
self._ttl = ttl
|
|
147
|
+
self._snapshot: Optional[dict] = None
|
|
148
|
+
|
|
149
|
+
def _load(self) -> None:
|
|
150
|
+
if self._snapshot is None:
|
|
151
|
+
self._snapshot = _load_cache(self._cache_path)
|
|
152
|
+
|
|
153
|
+
def _is_fresh(self) -> bool:
|
|
154
|
+
if not self._snapshot:
|
|
155
|
+
return False
|
|
156
|
+
try:
|
|
157
|
+
age = time.time() - self._snapshot["fetchedAt"]
|
|
158
|
+
return age <= self._snapshot.get("ttl", self._ttl)
|
|
159
|
+
except Exception:
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
def get(self, operation: str) -> str:
|
|
163
|
+
self._load()
|
|
164
|
+
if self._snapshot:
|
|
165
|
+
cached = self._snapshot.get("ids", {}).get(operation)
|
|
166
|
+
if cached:
|
|
167
|
+
return cached
|
|
168
|
+
return FALLBACK_QUERY_IDS.get(operation, "")
|
|
169
|
+
|
|
170
|
+
def refresh(self, operations: list[str], force: bool = False) -> None:
|
|
171
|
+
self._load()
|
|
172
|
+
if not force and self._is_fresh():
|
|
173
|
+
return
|
|
174
|
+
targets = set(operations)
|
|
175
|
+
try:
|
|
176
|
+
with httpx.Client() as client:
|
|
177
|
+
bundle_urls = _discover_bundles(client)
|
|
178
|
+
found = _fetch_and_extract(client, bundle_urls, targets)
|
|
179
|
+
except Exception:
|
|
180
|
+
return # silently keep using fallbacks
|
|
181
|
+
if not found:
|
|
182
|
+
return
|
|
183
|
+
snapshot = {
|
|
184
|
+
"fetchedAt": time.time(),
|
|
185
|
+
"ttl": self._ttl,
|
|
186
|
+
"ids": found,
|
|
187
|
+
}
|
|
188
|
+
try:
|
|
189
|
+
_save_cache(self._cache_path, snapshot)
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
self._snapshot = snapshot
|
|
193
|
+
|
|
194
|
+
def info(self) -> dict:
|
|
195
|
+
self._load()
|
|
196
|
+
if not self._snapshot:
|
|
197
|
+
return {"cached": False, "cachePath": str(self._cache_path)}
|
|
198
|
+
age = time.time() - self._snapshot.get("fetchedAt", 0)
|
|
199
|
+
return {
|
|
200
|
+
"cached": True,
|
|
201
|
+
"cachePath": str(self._cache_path),
|
|
202
|
+
"fetchedAt": self._snapshot.get("fetchedAt"),
|
|
203
|
+
"ttl": self._snapshot.get("ttl", self._ttl),
|
|
204
|
+
"ageSeconds": int(age),
|
|
205
|
+
"fresh": self._is_fresh(),
|
|
206
|
+
"ids": self._snapshot.get("ids", {}),
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# Module-level singleton — shared across all TwitterClient instances
|
|
211
|
+
query_id_store = QueryIdStore()
|