pyXcom 0.6.0__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.
pyxcom/storage.py ADDED
@@ -0,0 +1,152 @@
1
+ """Resumable public-post exports with auditable state and hashes."""
2
+
3
+ import csv
4
+ import gzip
5
+ import hashlib
6
+ import json
7
+ import os
8
+ from dataclasses import fields
9
+ from pathlib import Path
10
+
11
+ from .layout import internal_dir, migrate_collection
12
+ from .models import CollectionResult, Post
13
+ from .transport import now_utc
14
+ from .tables import export_tables
15
+
16
+
17
+ def _atomic_json(path: Path, value: dict) -> None:
18
+ temporary = path.with_suffix(path.suffix + ".tmp")
19
+ temporary.write_text(
20
+ json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8"
21
+ )
22
+ os.replace(temporary, path)
23
+
24
+
25
+ class PostStore:
26
+ def __init__(self, output_dir: str | Path, *, query: dict) -> None:
27
+ self.output_dir = Path(output_dir).expanduser()
28
+ migrate_collection(self.output_dir)
29
+ self._internal = internal_dir(self.output_dir)
30
+ self._jsonl = self._internal / "posts.jsonl"
31
+ self._state_path = self._internal / "state.json"
32
+ if self._state_path.exists():
33
+ self.state = json.loads(self._state_path.read_text(encoding="utf-8"))
34
+ if self.state.get("query") != query:
35
+ raise ValueError("Output directory belongs to a different query")
36
+ else:
37
+ self.state = {
38
+ "query": query,
39
+ "cursor": None,
40
+ "pages_fetched": 0,
41
+ "complete": False,
42
+ "reason": "not_started",
43
+ "missing_ids": [],
44
+ }
45
+ self._posts: dict[str, Post] = {}
46
+ if self._jsonl.exists():
47
+ for line in self._jsonl.read_text(encoding="utf-8").split("\n"):
48
+ if line.strip():
49
+ post = Post(**json.loads(line))
50
+ self._posts[post.id] = post
51
+
52
+ @property
53
+ def count(self) -> int:
54
+ return len(self._posts)
55
+
56
+ @property
57
+ def cursor(self) -> str | None:
58
+ return self.state["cursor"]
59
+
60
+ @property
61
+ def complete(self) -> bool:
62
+ return self.state["complete"]
63
+
64
+ def append_page(
65
+ self, posts: list[Post], next_cursor: str | None, *, count_page: bool = True
66
+ ) -> int:
67
+ added = 0
68
+ with self._jsonl.open("a", encoding="utf-8") as file:
69
+ for post in posts:
70
+ if post.id in self._posts:
71
+ continue
72
+ file.write(json.dumps(post.to_dict(), ensure_ascii=True) + "\n")
73
+ self._posts[post.id] = post
74
+ added += 1
75
+ file.flush()
76
+ os.fsync(file.fileno())
77
+ self.state.update(
78
+ cursor=next_cursor,
79
+ pages_fetched=self.state["pages_fetched"] + int(count_page),
80
+ reason="in_progress",
81
+ )
82
+ _atomic_json(self._state_path, self.state)
83
+ return added
84
+
85
+ def retain_role(self, role: str) -> None:
86
+ """Normalize a source timeline while preserving its original observations."""
87
+ kept = {
88
+ key: post for key, post in self._posts.items() if post.post_role == role
89
+ }
90
+ if len(kept) == len(self._posts):
91
+ return
92
+ backup = self._internal / "posts.before-timeline-filter.jsonl.gz"
93
+ if not backup.exists():
94
+ with gzip.open(backup, "wb") as stream:
95
+ stream.write(self._jsonl.read_bytes())
96
+ temporary = self._jsonl.with_suffix(".jsonl.tmp")
97
+ with temporary.open("w", encoding="utf-8") as stream:
98
+ for post in kept.values():
99
+ stream.write(json.dumps(post.to_dict(), ensure_ascii=True) + "\n")
100
+ stream.flush()
101
+ os.fsync(stream.fileno())
102
+ os.replace(temporary, self._jsonl)
103
+ self._posts = kept
104
+
105
+ def add_missing_ids(self, ids: set[str]) -> None:
106
+ self.state["missing_ids"] = sorted(
107
+ set(self.state.get("missing_ids", [])) | ids,
108
+ key=int,
109
+ )
110
+ _atomic_json(self._state_path, self.state)
111
+
112
+ def finish(self, *, complete: bool, reason: str) -> CollectionResult:
113
+ self.state.update(complete=complete, reason=reason, updated_at_utc=now_utc())
114
+ _atomic_json(self._state_path, self.state)
115
+ self._jsonl.touch(exist_ok=True)
116
+ csv_path = self._internal / "posts.csv"
117
+ rows = sorted(
118
+ self._posts.values(), key=lambda post: (post.created_at_utc, post.id)
119
+ )
120
+ names = [item.name for item in fields(Post)]
121
+ with csv_path.open("w", encoding="utf-8-sig", newline="") as file:
122
+ writer = csv.DictWriter(file, fieldnames=names)
123
+ writer.writeheader()
124
+ for post in rows:
125
+ row = post.to_dict()
126
+ for key in ("media_urls", "outbound_urls", "hashtags", "mentions"):
127
+ row[key] = json.dumps(row[key], ensure_ascii=False)
128
+ writer.writerow(row)
129
+ manifest = {
130
+ "query": self.state["query"],
131
+ "post_count": len(rows),
132
+ "pages_fetched": self.state["pages_fetched"],
133
+ "complete": complete,
134
+ "reason": reason,
135
+ "missing_ids": self.state.get("missing_ids", []),
136
+ "oldest_post_utc": rows[0].created_at_utc if rows else None,
137
+ "newest_post_utc": rows[-1].created_at_utc if rows else None,
138
+ "captured_at_utc": now_utc(),
139
+ "sha256": {
140
+ "posts.jsonl": hashlib.sha256(self._jsonl.read_bytes()).hexdigest(),
141
+ "posts.csv": hashlib.sha256(csv_path.read_bytes()).hexdigest(),
142
+ },
143
+ }
144
+ _atomic_json(self._internal / "manifest.json", manifest)
145
+ export_tables(self.output_dir)
146
+ return CollectionResult(
147
+ output_dir=self.output_dir,
148
+ post_count=len(rows),
149
+ pages_fetched=self.state["pages_fetched"],
150
+ complete=complete,
151
+ reason=reason,
152
+ )
pyxcom/tables.py ADDED
@@ -0,0 +1,308 @@
1
+ """Relational views of saved observations; never infer missing conversations."""
2
+
3
+ import csv
4
+ import hashlib
5
+ import json
6
+ import os
7
+ from dataclasses import fields
8
+ from pathlib import Path
9
+ from tempfile import NamedTemporaryFile
10
+
11
+ from .layout import migrate_collection, source_path
12
+ from .models import Post, Profile
13
+
14
+ _CONTENT = [
15
+ item.name
16
+ for item in fields(Post)
17
+ if item.name
18
+ not in {
19
+ "id",
20
+ "author_id",
21
+ "author_handle",
22
+ "post_role",
23
+ "post_type",
24
+ "conversation_id",
25
+ "in_reply_to_id",
26
+ "reposted_post_id",
27
+ }
28
+ ]
29
+ _USER = (
30
+ ["user_id", "username", "display_name"]
31
+ + [
32
+ item.name
33
+ for item in fields(Profile)
34
+ if item.name not in {"id", "handle", "name"}
35
+ ]
36
+ + ["profile_available"]
37
+ )
38
+ _POST = ["post_id", "author_id", "post_type"] + _CONTENT
39
+ _COMMENT = [
40
+ "comment_id",
41
+ "author_id",
42
+ "root_post_id",
43
+ "parent_post_id",
44
+ "depth",
45
+ "depth_status",
46
+ "parent_in_dataset",
47
+ "root_in_dataset",
48
+ ] + _CONTENT
49
+ _INTERACTION = [
50
+ "interaction_id",
51
+ "author_id",
52
+ "interaction_type",
53
+ "target_post_id",
54
+ ] + _CONTENT
55
+
56
+
57
+ def _hash(path: Path) -> str:
58
+ return hashlib.sha256(path.read_bytes()).hexdigest()
59
+
60
+
61
+ def _write_csv(path: Path, columns: list[str], rows: list[dict]) -> None:
62
+ with NamedTemporaryFile(
63
+ "w", encoding="utf-8-sig", newline="", dir=path.parent, delete=False
64
+ ) as stream:
65
+ temporary = Path(stream.name)
66
+ try:
67
+ writer = csv.DictWriter(stream, fieldnames=columns)
68
+ writer.writeheader()
69
+ for row in rows:
70
+ writer.writerow(
71
+ {
72
+ key: json.dumps(value, ensure_ascii=False)
73
+ if isinstance(value, (list, dict))
74
+ else value
75
+ for key, value in row.items()
76
+ }
77
+ )
78
+ stream.flush()
79
+ os.fsync(stream.fileno())
80
+ except BaseException:
81
+ temporary.unlink(missing_ok=True)
82
+ raise
83
+ os.replace(temporary, path)
84
+
85
+
86
+ def _depths(records: dict[str, Post]) -> dict[str, tuple[int | None, str]]:
87
+ """Iterative traversal avoids stack limits, including very long reply threads."""
88
+ resolved: dict[str, tuple[int | None, str]] = {}
89
+ for origin in records.values():
90
+ if origin.post_role != "comment" or origin.id in resolved:
91
+ continue
92
+ trail: list[str] = []
93
+ seen: set[str] = set()
94
+ current = origin
95
+ root = origin.conversation_id
96
+ while True:
97
+ if current.id in seen:
98
+ raise ValueError(f"Reply ancestry cycle at post {current.id}")
99
+ if current.id in resolved:
100
+ base, status = resolved[current.id]
101
+ break
102
+ seen.add(current.id)
103
+ trail.append(current.id)
104
+ if not root:
105
+ base, status = None, "missing_root_id"
106
+ break
107
+ if current.conversation_id != root:
108
+ base, status = None, "inconsistent_root"
109
+ break
110
+ parent = current.in_reply_to_id
111
+ if parent == current.id:
112
+ raise ValueError(f"Reply ancestry cycle at post {current.id}")
113
+ if parent == root:
114
+ if parent in records and records[parent].post_role != "main":
115
+ raise ValueError(f"Conversation root {root} is not a main post")
116
+ base, status = 0, "known"
117
+ break
118
+ if parent not in records:
119
+ base, status = None, "missing_parent"
120
+ break
121
+ ancestor = records[parent]
122
+ if ancestor.post_role != "comment":
123
+ base, status = None, "inconsistent_root"
124
+ break
125
+ if ancestor.conversation_id != root:
126
+ base, status = None, "inconsistent_root"
127
+ break
128
+ current = ancestor
129
+ for post_id in reversed(trail):
130
+ base = base + 1 if base is not None else None
131
+ resolved[post_id] = (base, status)
132
+ return resolved
133
+
134
+
135
+ def export_tables(output_dir: str | Path) -> dict:
136
+ """Write public relational CSV tables at the collection root."""
137
+ directory = Path(output_dir).expanduser()
138
+ migrate_collection(directory)
139
+ source = source_path(directory, "posts.jsonl")
140
+ records: dict[str, Post] = {}
141
+ for line in source.read_text(encoding="utf-8").split("\n"):
142
+ if not line.strip():
143
+ continue
144
+ data = json.loads(line)
145
+ for key in (
146
+ "id",
147
+ "author_id",
148
+ "conversation_id",
149
+ "in_reply_to_id",
150
+ "quoted_post_id",
151
+ "reposted_post_id",
152
+ ):
153
+ if data.get(key) is not None:
154
+ data[key] = str(data[key])
155
+ post = Post(**data)
156
+ if not post.id or not post.author_id:
157
+ raise ValueError("Post and author IDs must be nonempty")
158
+ if post.id in records and records[post.id] != post:
159
+ raise ValueError(f"Conflicting duplicate post ID: {post.id}")
160
+ records[post.id] = post
161
+ depths = _depths(records)
162
+ profiles_path = source_path(directory, "profiles.json")
163
+ profiles: dict[str, dict] = {}
164
+ if profiles_path.exists():
165
+ raw = json.loads(profiles_path.read_text(encoding="utf-8"))
166
+ entries = (
167
+ raw if isinstance(raw, list) else ([raw] if "id" in raw else raw.values())
168
+ )
169
+ for entry in entries:
170
+ user_id = str(entry["id"])
171
+ if user_id in profiles and profiles[user_id] != entry:
172
+ raise ValueError(f"Conflicting duplicate profile ID: {user_id}")
173
+ profiles[user_id] = entry
174
+ users: dict[str, dict] = {
175
+ user_id: {
176
+ "user_id": user_id,
177
+ "username": profile.get("handle"),
178
+ "display_name": profile.get("name"),
179
+ **{key: profile.get(key) for key in _USER[3:-1]},
180
+ "profile_available": True,
181
+ }
182
+ for user_id, profile in profiles.items()
183
+ }
184
+ posts, comments, interactions = [], [], []
185
+ main_ids = {p.id for p in records.values() if p.post_role == "main"}
186
+ content_ids = {p.id for p in records.values() if p.post_role != "repost"}
187
+ for post in sorted(records.values(), key=lambda p: (p.created_at_utc, p.id)):
188
+ profile = profiles.get(post.author_id)
189
+ users[post.author_id] = {
190
+ "user_id": post.author_id,
191
+ "username": profile.get("handle") if profile else post.author_handle,
192
+ "display_name": profile.get("name") if profile else None,
193
+ **{key: profile.get(key) if profile else None for key in _USER[3:-1]},
194
+ "profile_available": profile is not None,
195
+ }
196
+ data = post.to_dict()
197
+ payload = {key: data[key] for key in _CONTENT}
198
+ if post.post_role == "comment":
199
+ depth, status = depths[post.id]
200
+ comments.append(
201
+ {
202
+ "comment_id": post.id,
203
+ "author_id": post.author_id,
204
+ "root_post_id": post.conversation_id,
205
+ "parent_post_id": post.in_reply_to_id,
206
+ "depth": depth,
207
+ "depth_status": status,
208
+ "parent_in_dataset": post.in_reply_to_id in content_ids,
209
+ "root_in_dataset": post.conversation_id in main_ids,
210
+ **payload,
211
+ }
212
+ )
213
+ elif post.post_role == "repost":
214
+ interactions.append(
215
+ {
216
+ "interaction_id": post.id,
217
+ "author_id": post.author_id,
218
+ "interaction_type": "repost",
219
+ "target_post_id": post.reposted_post_id,
220
+ **payload,
221
+ }
222
+ )
223
+ else:
224
+ posts.append(
225
+ {
226
+ "post_id": post.id,
227
+ "author_id": post.author_id,
228
+ "post_type": post.post_type,
229
+ **payload,
230
+ }
231
+ )
232
+ target = directory
233
+ target.mkdir(exist_ok=True)
234
+ tables = {
235
+ "users.csv": (_USER, list(users.values())),
236
+ "posts.csv": (_POST, posts),
237
+ "comments.csv": (_COMMENT, comments),
238
+ }
239
+ if interactions:
240
+ tables["interactions.csv"] = (_INTERACTION, interactions)
241
+ for name, (columns, rows) in tables.items():
242
+ _write_csv(target / name, columns, rows)
243
+ if not interactions:
244
+ (target / "interactions.csv").unlink(missing_ok=True)
245
+ manifest: dict = {
246
+ "schema_version": "1.1",
247
+ "layout_version": "2.0",
248
+ "source_kind": "saved_post_observations",
249
+ "source_sha256": {
250
+ str(source.relative_to(directory)): _hash(source),
251
+ **(
252
+ {str(profiles_path.relative_to(directory)): _hash(profiles_path)}
253
+ if profiles_path.exists()
254
+ else {}
255
+ ),
256
+ },
257
+ "counts": {
258
+ "users": len(users),
259
+ "posts": len(posts),
260
+ "comments": len(comments),
261
+ "interactions": len(interactions),
262
+ "source_records": len(records),
263
+ },
264
+ "missing_root_count": sum(not row["root_in_dataset"] for row in comments),
265
+ "missing_parent_count": sum(not row["parent_in_dataset"] for row in comments),
266
+ "unknown_depth_count": sum(row["depth"] is None for row in comments),
267
+ "conversation_coverage": "observed_records_only",
268
+ "sha256": {name: _hash(target / name) for name in tables},
269
+ }
270
+ collection_path = source_path(directory, "manifest.json")
271
+ if collection_path.exists() and collection_path != target / "manifest.json":
272
+ collection = json.loads(collection_path.read_text(encoding="utf-8"))
273
+ for key in (
274
+ "query",
275
+ "pages_fetched",
276
+ "complete",
277
+ "reason",
278
+ "captured_at_utc",
279
+ "oldest_post_utc",
280
+ "newest_post_utc",
281
+ "missing_ids",
282
+ ):
283
+ if key in collection:
284
+ manifest[key] = collection[key]
285
+ state_path = source_path(directory, "state.json")
286
+ if manifest.get("query", {}).get("kind") == "post_comments" and state_path.exists():
287
+ state = json.loads(state_path.read_text(encoding="utf-8")).get(
288
+ "conversation", {}
289
+ )
290
+ manifest["comment_collection"] = {
291
+ "max_depth": manifest["query"]["max_depth"],
292
+ "pending_requests": len(state.get("queue", [])),
293
+ "unresolved_or_deferred_records": len(state.get("pending", {})),
294
+ "pagination_warnings": sorted(set(state.get("pagination_warnings", []))),
295
+ "depth_counts": {
296
+ str(depth): sum(row["depth"] == depth for row in comments)
297
+ for depth in sorted(
298
+ {row["depth"] for row in comments if row["depth"] is not None}
299
+ )
300
+ },
301
+ }
302
+ with NamedTemporaryFile("w", encoding="utf-8", dir=target, delete=False) as stream:
303
+ temporary = Path(stream.name)
304
+ json.dump(manifest, stream, ensure_ascii=False, indent=2)
305
+ stream.flush()
306
+ os.fsync(stream.fileno())
307
+ os.replace(temporary, target / "manifest.json")
308
+ return manifest
pyxcom/transport.py ADDED
@@ -0,0 +1,238 @@
1
+ """Cookie-authenticated, read-only requests to X's current web endpoints."""
2
+
3
+ import json
4
+ import re
5
+ import time
6
+ from datetime import datetime, timezone
7
+ from urllib.parse import urlsplit
8
+ import httpx
9
+
10
+ from .errors import APIError, RateLimitError
11
+ from .models import Post, Profile
12
+ from .parse import parse_post, parse_profile, single_post
13
+
14
+ _MAIN_SCRIPT = re.compile(
15
+ r"https://abs\.twimg\.com/responsive-web/client-web/main\.[^\"\s<>]+\.js"
16
+ )
17
+ _OPERATION = re.compile(r'queryId:"([^\"]+)",operationName:"([^\"]+)"')
18
+ _BEARER = re.compile(r"Bearer (A[A-Za-z0-9%]+)")
19
+ TWEET_FEATURES = {
20
+ "view_counts_everywhere_api_enabled": True,
21
+ "longform_notetweets_consumption_enabled": True,
22
+ "longform_notetweets_rich_text_read_enabled": True,
23
+ }
24
+
25
+
26
+ def now_utc() -> str:
27
+ return datetime.now(timezone.utc).isoformat()
28
+
29
+
30
+ class XTransport:
31
+ """Discover query IDs from X's web bundle instead of pinning twikit routes."""
32
+
33
+ def __init__(
34
+ self, cookies: dict[str, str], *, proxy: str | None = None, timeout: float = 30
35
+ ) -> None:
36
+ cookie_jar = httpx.Cookies()
37
+ for name, value in cookies.items():
38
+ cookie_jar.set(name, value, domain=".x.com", path="/")
39
+ self._csrf = cookies["ct0"]
40
+ self._x = httpx.Client(
41
+ proxy=proxy, cookies=cookie_jar, timeout=timeout, follow_redirects=True
42
+ )
43
+ self._assets = httpx.Client(proxy=proxy, timeout=timeout, follow_redirects=True)
44
+ self._operations: dict[str, str] = {}
45
+ self._bearer: str | None = None
46
+
47
+ def close(self) -> None:
48
+ self._x.close()
49
+ self._assets.close()
50
+
51
+ def _get(self, client: httpx.Client, url: str, **kwargs) -> httpx.Response:
52
+ """Retry transient transport/5xx errors, then surface a safe message."""
53
+ for attempt in range(3):
54
+ try:
55
+ response = client.get(url, **kwargs)
56
+ except httpx.RequestError as exc:
57
+ if attempt == 2:
58
+ raise APIError(
59
+ f"Network request to {urlsplit(url).netloc} failed after 3 attempts"
60
+ ) from exc
61
+ else:
62
+ if response.status_code not in {502, 503, 504} or attempt == 2:
63
+ return response
64
+ time.sleep(2**attempt)
65
+ raise APIError("Network retry loop ended unexpectedly")
66
+
67
+ def discover(self, *, refresh: bool = False) -> None:
68
+ if self._operations and not refresh:
69
+ return
70
+ response = self._get(self._x, "https://x.com/")
71
+ if response.status_code != 200:
72
+ raise APIError(f"X homepage returned HTTP {response.status_code}")
73
+ match = _MAIN_SCRIPT.search(response.text)
74
+ if not match:
75
+ raise APIError(
76
+ "X web bundle was not found; the site layout may have changed"
77
+ )
78
+ js_response = self._get(self._assets, match.group(0))
79
+ if js_response.status_code != 200:
80
+ raise APIError(f"X web bundle returned HTTP {js_response.status_code}")
81
+ operations = {
82
+ name: query_id for query_id, name in _OPERATION.findall(js_response.text)
83
+ }
84
+ bearer = _BEARER.search(js_response.text)
85
+ if not operations or not bearer:
86
+ raise APIError("X web bundle no longer exposes the expected read routes")
87
+ self._operations = operations
88
+ self._bearer = bearer.group(1)
89
+
90
+ def _headers(self) -> dict[str, str]:
91
+ return {
92
+ "authorization": f"Bearer {self._bearer}",
93
+ "x-csrf-token": self._csrf,
94
+ "x-twitter-auth-type": "OAuth2Session",
95
+ "x-twitter-active-user": "yes",
96
+ "x-twitter-client-language": "en",
97
+ "referer": "https://x.com/",
98
+ "user-agent": (
99
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
100
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
101
+ "Chrome/122.0.0.0 Safari/537.36"
102
+ ),
103
+ }
104
+
105
+ def graphql(
106
+ self,
107
+ operation: str,
108
+ variables: dict,
109
+ *,
110
+ features: dict | None = None,
111
+ field_toggles: dict | None = None,
112
+ ) -> dict:
113
+ self.discover()
114
+ if operation not in self._operations:
115
+ raise APIError(f"X web bundle has no {operation} operation")
116
+ params = {
117
+ "variables": json.dumps(variables, separators=(",", ":")),
118
+ "features": json.dumps(features or {}, separators=(",", ":")),
119
+ }
120
+ if field_toggles is not None:
121
+ params["fieldToggles"] = json.dumps(field_toggles, separators=(",", ":"))
122
+ query_id = self._operations[operation]
123
+ url = f"https://x.com/i/api/graphql/{query_id}/{operation}"
124
+ response = self._get(self._x, url, params=params, headers=self._headers())
125
+ if response.status_code == 429:
126
+ reset = response.headers.get("x-rate-limit-reset")
127
+ raise RateLimitError(
128
+ f"X rate-limited {operation}",
129
+ int(reset) if reset and reset.isdigit() else None,
130
+ )
131
+ if response.status_code == 404 and not response.content:
132
+ # X also uses an empty 404 when a read route is temporarily limited.
133
+ reset = response.headers.get("x-rate-limit-reset")
134
+ raise RateLimitError(
135
+ f"X returned an empty 404 for {operation}; retry after the rate window",
136
+ int(reset) if reset and reset.isdigit() else None,
137
+ )
138
+ if response.status_code != 200:
139
+ raise APIError(f"X returned HTTP {response.status_code} for {operation}")
140
+ try:
141
+ payload = response.json()
142
+ except ValueError as exc:
143
+ raise APIError(f"X returned non-JSON data for {operation}") from exc
144
+ if not isinstance(payload, dict) or payload.get("errors"):
145
+ raise APIError(f"X returned an error for {operation}")
146
+ return payload
147
+
148
+ def get_profile(self, handle: str) -> Profile:
149
+ payload = self.graphql(
150
+ "UserByScreenName",
151
+ {"screen_name": handle, "withSafetyModeUserFields": True},
152
+ )
153
+ return parse_profile(payload, captured_at_utc=now_utc())
154
+
155
+ def get_post(self, post_id: str) -> Post:
156
+ payload = self.graphql(
157
+ "TweetResultByRestId",
158
+ {
159
+ "tweetId": post_id,
160
+ "withCommunity": False,
161
+ "includePromotedContent": False,
162
+ "withVoice": False,
163
+ },
164
+ features=TWEET_FEATURES,
165
+ field_toggles={},
166
+ )
167
+ return single_post(payload, captured_at_utc=now_utc())
168
+
169
+ def get_posts(self, post_ids: list[str]) -> list[Post]:
170
+ if len(post_ids) > 50:
171
+ raise ValueError("get_posts accepts at most 50 IDs per request")
172
+ if not post_ids:
173
+ return []
174
+ payload = self.graphql(
175
+ "TweetResultsByRestIds",
176
+ {
177
+ "tweetIds": post_ids,
178
+ "includePromotedContent": True,
179
+ "withBirdwatchNotes": True,
180
+ "withVoice": True,
181
+ "withCommunity": True,
182
+ },
183
+ features=TWEET_FEATURES,
184
+ field_toggles={},
185
+ )
186
+ found: list[Post] = []
187
+ for item in payload.get("data", {}).get("tweetResult", []):
188
+ result = item.get("result")
189
+ if isinstance(result, dict):
190
+ post = parse_post(result, captured_at_utc=now_utc())
191
+ if post is not None:
192
+ found.append(post)
193
+ return found
194
+
195
+ def conversation_page(self, post_id: str, cursor: str | None = None) -> dict:
196
+ """Read a post's conversation, including replies by other authors.
197
+
198
+ A focal post may itself be a reply; callers can expand such branches to
199
+ discover nested comments that the root conversation omits.
200
+ """
201
+ variables = {
202
+ "focalTweetId": post_id,
203
+ "with_rux_injections": False,
204
+ "includePromotedContent": False,
205
+ "withCommunity": True,
206
+ "withQuickPromoteEligibilityTweetFields": True,
207
+ "withBirdwatchNotes": True,
208
+ "withVoice": True,
209
+ "withV2Timeline": True,
210
+ }
211
+ if cursor:
212
+ variables["cursor"] = cursor
213
+ return self.graphql(
214
+ "TweetDetail",
215
+ variables,
216
+ features=TWEET_FEATURES,
217
+ field_toggles={"withArticleRichContentState": False},
218
+ )
219
+
220
+ def user_timeline_page(
221
+ self, user_id: str, *, timeline: str, cursor: str | None = None
222
+ ) -> dict:
223
+ if timeline not in {"posts", "replies"}:
224
+ raise ValueError("timeline must be posts or replies")
225
+ operation = (
226
+ "UserOriginalsTimeline" if timeline == "posts" else "UserRepliesTimeline"
227
+ )
228
+ variables = {
229
+ "userId": user_id,
230
+ "count": 20,
231
+ "includePromotedContent": True,
232
+ "withQuickPromoteEligibilityTweetFields": True,
233
+ "withVoice": True,
234
+ "withV2Timeline": True,
235
+ }
236
+ if cursor:
237
+ variables["cursor"] = cursor
238
+ return self.graphql(operation, variables, features=TWEET_FEATURES)