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/__init__.py +29 -0
- pyxcom/auth.py +66 -0
- pyxcom/batch.py +260 -0
- pyxcom/cli.py +300 -0
- pyxcom/client.py +900 -0
- pyxcom/comments.py +235 -0
- pyxcom/errors.py +29 -0
- pyxcom/layout.py +124 -0
- pyxcom/models.py +96 -0
- pyxcom/parse.py +217 -0
- pyxcom/schema.py +276 -0
- pyxcom/search.py +94 -0
- pyxcom/storage.py +152 -0
- pyxcom/tables.py +308 -0
- pyxcom/transport.py +238 -0
- pyxcom/validate.py +112 -0
- pyxcom/validate_tables.py +98 -0
- pyxcom-0.6.0.dist-info/METADATA +253 -0
- pyxcom-0.6.0.dist-info/RECORD +22 -0
- pyxcom-0.6.0.dist-info/WHEEL +4 -0
- pyxcom-0.6.0.dist-info/entry_points.txt +2 -0
- pyxcom-0.6.0.dist-info/licenses/LICENSE +21 -0
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)
|