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/comments.py ADDED
@@ -0,0 +1,235 @@
1
+ """Bounded, resumable traversal of public replies to a root X post."""
2
+
3
+ import time
4
+ from dataclasses import replace
5
+ from typing import TYPE_CHECKING, Iterator
6
+
7
+ from .errors import ParseError, RateLimitError
8
+ from .models import Post
9
+ from .parse import _walk, parse_post, parse_profile
10
+ from .transport import now_utc
11
+
12
+ if TYPE_CHECKING:
13
+ from .client import XClient
14
+
15
+
16
+ def parse_conversation(data: dict) -> tuple[list[Post], list[str], dict]:
17
+ """Read timeline content only, excluding embedded quotes and recommendations."""
18
+ instructions = next(
19
+ (
20
+ n["instructions"]
21
+ for n in _walk(data.get("data", {}))
22
+ if isinstance(n.get("instructions"), list)
23
+ ),
24
+ None,
25
+ )
26
+ if instructions is None:
27
+ raise ParseError("X conversation response contains no timeline instructions")
28
+ posts: dict[str, Post] = {}
29
+ cursors: list[str] = []
30
+ profiles: dict[str, dict] = {}
31
+ captured = now_utc()
32
+
33
+ def visit(node):
34
+ if isinstance(node, list):
35
+ for item in node:
36
+ visit(item)
37
+ elif isinstance(node, dict):
38
+ if node.get("promotedMetadata"):
39
+ return
40
+ if "tweet_results" in node:
41
+ result = (node.get("tweet_results") or {}).get("result")
42
+ if isinstance(result, dict):
43
+ post = parse_post(result, captured_at_utc=captured)
44
+ if post:
45
+ posts[post.id] = post
46
+ tweet = result.get("tweet", result)
47
+ user = (
48
+ tweet.get("core", {})
49
+ .get("user_results", {})
50
+ .get("result", {})
51
+ )
52
+ try:
53
+ profile = parse_profile(user, captured_at_utc=captured)
54
+ profiles[profile.handle] = profile.to_dict()
55
+ except ParseError:
56
+ pass
57
+ return # Never recurse into a quoted tweet or an author's other data.
58
+ kind = node.get("cursorType")
59
+ if kind in (
60
+ "Bottom",
61
+ "ShowMore",
62
+ "ShowMoreThreads",
63
+ "ShowMoreReplies",
64
+ ) and node.get("value"):
65
+ cursors.append(node["value"])
66
+ for key in (
67
+ "entries",
68
+ "entry",
69
+ "content",
70
+ "items",
71
+ "item",
72
+ "itemContent",
73
+ "moduleItems",
74
+ ):
75
+ if key in node:
76
+ visit(node[key])
77
+
78
+ visit(instructions)
79
+ return list(posts.values()), list(dict.fromkeys(cursors)), profiles
80
+
81
+
82
+ class CommentTraversal:
83
+ def __init__(
84
+ self,
85
+ client: "XClient",
86
+ root_id: str,
87
+ *,
88
+ max_depth: int,
89
+ max_comments: int | None,
90
+ max_pages: int | None,
91
+ records: dict[str, Post] | None = None,
92
+ state: dict | None = None,
93
+ ):
94
+ if max_depth is None:
95
+ raise ValueError("max_depth must be a positive integer")
96
+ for name, value in (
97
+ ("max_depth", max_depth),
98
+ ("max_comments", max_comments),
99
+ ("max_pages", max_pages),
100
+ ):
101
+ if value is not None and (
102
+ isinstance(value, bool) or not isinstance(value, int) or value < 1
103
+ ):
104
+ raise ValueError(f"{name} must be a positive integer")
105
+ self.client = client
106
+ self.root_id = root_id
107
+ self.max_depth, self.max_comments, self.max_pages = (
108
+ max_depth,
109
+ max_comments,
110
+ max_pages,
111
+ )
112
+ self.records = dict(records or {})
113
+ self.state = state if state is not None else {}
114
+ self.state.setdefault("queue", [[root_id, None]])
115
+ self.state.setdefault("visited", [])
116
+ self.state.setdefault("expanded", [root_id])
117
+ self.state.setdefault("pending", {})
118
+ self.state.setdefault("depths", {root_id: 0})
119
+ self.state.setdefault("profiles", {})
120
+ self.state.setdefault("pagination_warnings", [])
121
+ self.state.setdefault("empty_pages", {})
122
+ self.complete = False
123
+ self.reason = "not_started"
124
+ self.pages_fetched = 0
125
+ self.page_was_fetched = False
126
+
127
+ def _accept_pending(self) -> list[Post]:
128
+ added: list[Post] = []
129
+ pending = self.state["pending"]
130
+ while True:
131
+ progressed = False
132
+ for key, value in list(pending.items()):
133
+ post = Post(**value)
134
+ parent_depth = self.state["depths"].get(post.in_reply_to_id)
135
+ if parent_depth is None:
136
+ continue
137
+ depth = parent_depth + 1
138
+ if depth > self.max_depth or key in self.records:
139
+ self.state["depths"][key] = depth
140
+ del pending[key]
141
+ progressed = True
142
+ continue
143
+ if (
144
+ self.max_comments is not None
145
+ and len(self.records) - 1 >= self.max_comments
146
+ ):
147
+ return added
148
+ self.records[key] = post
149
+ self.state["depths"][key] = depth
150
+ del pending[key]
151
+ added.append(post)
152
+ progressed = True
153
+ if (
154
+ depth < self.max_depth
155
+ and post.reply_count != 0
156
+ and key not in self.state["expanded"]
157
+ ):
158
+ self.state["queue"].append([key, None])
159
+ self.state["expanded"].append(key)
160
+ if not progressed:
161
+ return added
162
+
163
+ def pages(self) -> Iterator[list[Post]]:
164
+ if self.root_id not in self.records:
165
+ root = self.client.get_post(self.root_id)
166
+ if root.in_reply_to_id or root.reposted_post_id:
167
+ raise ValueError(
168
+ "post_id_or_url must identify a main post, not a reply or repost"
169
+ )
170
+ self.records[root.id] = root
171
+ self.page_was_fetched = False
172
+ yield [root]
173
+ while True:
174
+ self.page_was_fetched = False
175
+ ready = self._accept_pending()
176
+ if ready:
177
+ yield ready
178
+ if (
179
+ self.max_comments is not None
180
+ and len(self.records) - 1 >= self.max_comments
181
+ ):
182
+ self.reason = "comment_limit"
183
+ return
184
+ if not self.state["queue"]:
185
+ unresolved = len(self.state["pending"])
186
+ self.state["unresolved_count"] = unresolved
187
+ self.complete = not unresolved and not self.state["pagination_warnings"]
188
+ self.reason = (
189
+ "visible_source_end" if self.complete else "partial_conversation"
190
+ )
191
+ return
192
+ if self.max_pages is not None and self.pages_fetched >= self.max_pages:
193
+ self.reason = "page_limit"
194
+ return
195
+ task = self.state["queue"][0]
196
+ focal, cursor = task
197
+ try:
198
+ payload = self.client._x.conversation_page(focal, cursor=cursor)
199
+ except RateLimitError as exc:
200
+ self.state["rate_reset_at"] = exc.reset_at
201
+ self.reason = "rate_limited"
202
+ return
203
+ posts, cursors, profiles = parse_conversation(payload)
204
+ self.state.pop("rate_reset_at", None)
205
+ self.state["queue"].pop(0)
206
+ self.state["visited"].append(task)
207
+ self.state["profiles"].update(profiles)
208
+ relevant = [
209
+ p
210
+ for p in posts
211
+ if p.conversation_id == self.root_id
212
+ and p.in_reply_to_id
213
+ and p.id not in self.records
214
+ ]
215
+ for post in relevant:
216
+ self.state["pending"][post.id] = replace(
217
+ post,
218
+ discovery_source="x_conversation",
219
+ discovery_url=f"https://x.com/i/status/{focal}",
220
+ ).to_dict()
221
+ empty = self.state["empty_pages"].get(focal, 0)
222
+ self.state["empty_pages"][focal] = empty + 1 if not relevant else 0
223
+ for next_cursor in cursors:
224
+ follow = [focal, next_cursor]
225
+ if follow in self.state["visited"]:
226
+ self.state["pagination_warnings"].append("repeated_cursor")
227
+ elif self.state["empty_pages"][focal] >= 2:
228
+ self.state["pagination_warnings"].append("empty_page_limit")
229
+ elif follow not in self.state["queue"]:
230
+ self.state["queue"].append(follow)
231
+ self.pages_fetched += 1
232
+ self.page_was_fetched = True
233
+ yield self._accept_pending()
234
+ if self.client.delay:
235
+ time.sleep(self.client.delay)
pyxcom/errors.py ADDED
@@ -0,0 +1,29 @@
1
+ """Typed failures surfaced by pyXcom."""
2
+
3
+
4
+ class PyXcomError(Exception):
5
+ """Base package exception."""
6
+
7
+
8
+ class AuthenticationError(PyXcomError):
9
+ """No usable browser login was found."""
10
+
11
+
12
+ class CaptureError(PyXcomError):
13
+ """The browser could not capture the requested X read endpoint."""
14
+
15
+
16
+ class APIError(PyXcomError):
17
+ """X returned an unusable response."""
18
+
19
+
20
+ class RateLimitError(APIError):
21
+ """X limited the requested endpoint."""
22
+
23
+ def __init__(self, message: str, reset_at: int | None = None) -> None:
24
+ super().__init__(message)
25
+ self.reset_at = reset_at
26
+
27
+
28
+ class ParseError(PyXcomError):
29
+ """The response shape changed or omitted expected data."""
pyxcom/layout.py ADDED
@@ -0,0 +1,124 @@
1
+ """Stable public tables and private, resumable collection artifacts."""
2
+
3
+ import csv
4
+ import json
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ _INTERNAL_FILES = (
9
+ "posts.jsonl",
10
+ "state.json",
11
+ "profiles.json",
12
+ "account_manifest.json",
13
+ "report.md",
14
+ "schema.json",
15
+ "schema_report.md",
16
+ "posts.jsonl.before-role-schema.gz",
17
+ )
18
+
19
+
20
+ def internal_dir(output: str | Path) -> Path:
21
+ return Path(output).expanduser() / ".pyxcom"
22
+
23
+
24
+ def source_path(output: str | Path, name: str) -> Path:
25
+ private = internal_dir(output) / name
26
+ legacy = Path(output).expanduser() / name
27
+ return private if private.exists() or not legacy.exists() else legacy
28
+
29
+
30
+ def child_dir(output: str | Path, name: str) -> Path:
31
+ return internal_dir(output) / name
32
+
33
+
34
+ def _same_contents(left: Path, right: Path) -> bool:
35
+ """Compare every directory entry and file byte before reusing a backup."""
36
+ if left.is_symlink() or right.is_symlink():
37
+ return False
38
+ if left.is_dir() and right.is_dir():
39
+ left_entries = {entry.name: entry for entry in left.iterdir()}
40
+ right_entries = {entry.name: entry for entry in right.iterdir()}
41
+ return left_entries.keys() == right_entries.keys() and all(
42
+ _same_contents(entry, right_entries[name])
43
+ for name, entry in left_entries.items()
44
+ )
45
+ return (
46
+ left.is_file() and right.is_file() and left.read_bytes() == right.read_bytes()
47
+ )
48
+
49
+
50
+ def _move_preserving(source: Path, target: Path, backup: Path) -> None:
51
+ if not source.exists():
52
+ return
53
+ if target.exists():
54
+ if (
55
+ source.is_dir()
56
+ or target.is_dir()
57
+ or source.read_bytes() != target.read_bytes()
58
+ ):
59
+ raise ValueError(f"Conflicting migration paths: {source} and {target}")
60
+ if backup.exists():
61
+ if not _same_contents(source, backup):
62
+ raise ValueError(f"Conflicting legacy backup: {backup}")
63
+ else:
64
+ backup.parent.mkdir(parents=True, exist_ok=True)
65
+ if source.is_dir():
66
+ shutil.copytree(source, backup)
67
+ else:
68
+ shutil.copy2(source, backup)
69
+ target.parent.mkdir(parents=True, exist_ok=True)
70
+ if target.exists():
71
+ source.unlink()
72
+ else:
73
+ source.rename(target)
74
+
75
+
76
+ def migrate_collection(output: str | Path) -> Path:
77
+ """Move recognized legacy artifacts, preserving backups and unrelated files.
78
+
79
+ Repeated calls are safe. Conflicting destinations raise rather than overwrite.
80
+ Normalized root tables are never mistaken for legacy mixed observations.
81
+ """
82
+ directory = Path(output).expanduser()
83
+ private = internal_dir(directory)
84
+ private.mkdir(parents=True, exist_ok=True)
85
+ backup = private / "legacy"
86
+ for name in _INTERNAL_FILES:
87
+ _move_preserving(directory / name, private / name, backup / name)
88
+ root_manifest = directory / "manifest.json"
89
+ if root_manifest.exists():
90
+ data = json.loads(root_manifest.read_text(encoding="utf-8"))
91
+ if "post_count" in data and "counts" not in data:
92
+ _move_preserving(
93
+ root_manifest, private / "manifest.json", backup / "manifest.json"
94
+ )
95
+ root_csv = directory / "posts.csv"
96
+ if root_csv.exists():
97
+ with root_csv.open(encoding="utf-8-sig", newline="") as stream:
98
+ header = next(csv.reader(stream), [])
99
+ if "id" in header and ("post_role" in header or "author_handle" in header):
100
+ _move_preserving(root_csv, private / "posts.csv", backup / "posts.csv")
101
+ # Only folders with a collection checkpoint are owned collection scopes.
102
+ for folder in list(directory.iterdir()):
103
+ if (
104
+ folder.is_dir()
105
+ and folder.name not in {".pyxcom", "tables"}
106
+ and (folder / "state.json").exists()
107
+ ):
108
+ _move_preserving(folder, private / folder.name, backup / folder.name)
109
+ old_tables = directory / "tables"
110
+ if (old_tables / "manifest.json").exists():
111
+ data = json.loads((old_tables / "manifest.json").read_text(encoding="utf-8"))
112
+ if data.get("source_kind") == "saved_post_observations":
113
+ _move_preserving(old_tables, private / "previous_tables", backup / "tables")
114
+ for folder in list(private.iterdir()):
115
+ if (
116
+ folder.is_dir()
117
+ and folder.name not in {"legacy", "previous_tables"}
118
+ and (
119
+ (folder / "state.json").exists()
120
+ or (internal_dir(folder) / "state.json").exists()
121
+ )
122
+ ):
123
+ migrate_collection(folder)
124
+ return directory
pyxcom/models.py ADDED
@@ -0,0 +1,96 @@
1
+ """Portable public-data records returned by pyXcom."""
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+ from pathlib import Path
5
+
6
+
7
+ def classify_post(
8
+ in_reply_to_id: str | None,
9
+ quoted_post_id: str | None,
10
+ reposted_post_id: str | None,
11
+ ) -> tuple[str, str]:
12
+ """Return (role, type) from X's explicit post relationships."""
13
+ if reposted_post_id:
14
+ return "repost", "repost"
15
+ if in_reply_to_id:
16
+ return "comment", "reply"
17
+ if quoted_post_id:
18
+ return "main", "quote"
19
+ return "main", "original"
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Profile:
24
+ id: str
25
+ handle: str
26
+ name: str
27
+ description: str | None = None
28
+ created_at_utc: str | None = None
29
+ followers_count: int | None = None
30
+ following_count: int | None = None
31
+ posts_count: int | None = None
32
+ media_count: int | None = None
33
+ verified: bool | None = None
34
+ url: str | None = None
35
+ captured_at_utc: str | None = None
36
+
37
+ def to_dict(self) -> dict:
38
+ return asdict(self)
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Post:
43
+ id: str
44
+ author_id: str
45
+ author_handle: str
46
+ created_at_utc: str
47
+ text: str
48
+ url: str
49
+ post_role: str = ""
50
+ post_type: str = ""
51
+ language: str | None = None
52
+ conversation_id: str | None = None
53
+ in_reply_to_id: str | None = None
54
+ quoted_post_id: str | None = None
55
+ reposted_post_id: str | None = None
56
+ reply_count: int | None = None
57
+ repost_count: int | None = None
58
+ like_count: int | None = None
59
+ view_count: int | None = None
60
+ quote_count: int | None = None
61
+ bookmark_count: int | None = None
62
+ media_urls: list[str] = field(default_factory=list)
63
+ outbound_urls: list[str] = field(default_factory=list)
64
+ hashtags: list[str] = field(default_factory=list)
65
+ mentions: list[str] = field(default_factory=list)
66
+ discovery_source: str | None = None
67
+ discovery_url: str | None = None
68
+ captured_at_utc: str | None = None
69
+
70
+ def __post_init__(self) -> None:
71
+ role, kind = classify_post(
72
+ self.in_reply_to_id, self.quoted_post_id, self.reposted_post_id
73
+ )
74
+ object.__setattr__(self, "post_role", role)
75
+ object.__setattr__(self, "post_type", kind)
76
+
77
+ def to_dict(self) -> dict:
78
+ return asdict(self)
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class CollectionResult:
83
+ output_dir: Path
84
+ post_count: int
85
+ pages_fetched: int
86
+ complete: bool
87
+ reason: str
88
+
89
+ def to_dict(self) -> dict:
90
+ return {
91
+ "output_dir": str(self.output_dir),
92
+ "post_count": self.post_count,
93
+ "pages_fetched": self.pages_fetched,
94
+ "complete": self.complete,
95
+ "reason": self.reason,
96
+ }