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 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()