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 ADDED
@@ -0,0 +1,29 @@
1
+ """pyXcom: public X collection with an existing Chrome or Edge login."""
2
+
3
+ from .client import XClient
4
+ from .errors import APIError, AuthenticationError, PyXcomError, RateLimitError
5
+ from .models import CollectionResult, Post, Profile, classify_post
6
+ from .schema import apply_role_schema, schema_summary, write_schema_report
7
+ from .tables import export_tables
8
+ from .validate_tables import validate_tables
9
+ from .validate import finalize_collection, validate_collection
10
+
11
+ __all__ = [
12
+ "XClient",
13
+ "PyXcomError",
14
+ "AuthenticationError",
15
+ "APIError",
16
+ "RateLimitError",
17
+ "export_tables",
18
+ "validate_tables",
19
+ "Profile",
20
+ "Post",
21
+ "CollectionResult",
22
+ "classify_post",
23
+ "schema_summary",
24
+ "write_schema_report",
25
+ "apply_role_schema",
26
+ "finalize_collection",
27
+ "validate_collection",
28
+ ]
29
+ __version__ = "0.6.0"
pyxcom/auth.py ADDED
@@ -0,0 +1,66 @@
1
+ """Load only X authentication cookies from an existing local browser profile."""
2
+
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import browser_cookie3
8
+
9
+ from .errors import AuthenticationError
10
+
11
+
12
+ def profile_cookie_db(browser: str, profile: str) -> Path:
13
+ """Resolve a Chrome/Edge profile cookie DB on supported desktop systems."""
14
+ if browser not in {"chrome", "edge"}:
15
+ raise AuthenticationError("Supported browsers are chrome and edge")
16
+ if sys.platform == "darwin":
17
+ folder = "Google/Chrome" if browser == "chrome" else "Microsoft Edge"
18
+ return (
19
+ Path.home() / "Library/Application Support" / folder / profile / "Cookies"
20
+ )
21
+ if sys.platform.startswith("linux"):
22
+ folder = "google-chrome" if browser == "chrome" else "microsoft-edge"
23
+ return Path.home() / ".config" / folder / profile / "Cookies"
24
+ if sys.platform == "win32":
25
+ local = Path(os.environ.get("LOCALAPPDATA", ""))
26
+ folder = "Google/Chrome" if browser == "chrome" else "Microsoft/Edge"
27
+ return local / folder / "User Data" / profile / "Network" / "Cookies"
28
+ raise AuthenticationError("Unsupported operating system; pass cookie_db explicitly")
29
+
30
+
31
+ def load_x_cookies(
32
+ browser: str = "chrome",
33
+ *,
34
+ profile: str | None = None,
35
+ cookie_db: str | Path | None = None,
36
+ ) -> dict[str, str]:
37
+ """Return auth_token and ct0 in memory; never save or log their values."""
38
+ if browser not in {"chrome", "edge"}:
39
+ raise AuthenticationError("Supported browsers are chrome and edge")
40
+ path = Path(cookie_db).expanduser() if cookie_db else None
41
+ if path is None and profile:
42
+ path = profile_cookie_db(browser, profile)
43
+ if path is not None and not path.exists():
44
+ raise AuthenticationError(
45
+ "Browser cookie database not found at the selected path"
46
+ )
47
+ try:
48
+ reader = browser_cookie3.chrome if browser == "chrome" else browser_cookie3.edge
49
+ kwargs = {"domain_name": ".x.com"}
50
+ if path is not None:
51
+ kwargs["cookie_file"] = str(path)
52
+ jar = reader(**kwargs)
53
+ except Exception as exc:
54
+ raise AuthenticationError(
55
+ "Could not read the selected browser profile"
56
+ ) from exc
57
+ cookies = {
58
+ cookie.name: cookie.value
59
+ for cookie in jar
60
+ if cookie.name in {"auth_token", "ct0"} and cookie.domain.lstrip(".") == "x.com"
61
+ }
62
+ if set(cookies) != {"auth_token", "ct0"}:
63
+ raise AuthenticationError(
64
+ "No active X login in this browser profile; sign in to x.com or select another profile"
65
+ )
66
+ return cookies
pyxcom/batch.py ADDED
@@ -0,0 +1,260 @@
1
+ """Resumable multi-account collection, audit, and summary reporting."""
2
+
3
+ import json
4
+ import time
5
+ from collections import Counter
6
+ from pathlib import Path
7
+ from statistics import median
8
+ from typing import TYPE_CHECKING, Callable
9
+
10
+ from .errors import PyXcomError
11
+ from .layout import child_dir, internal_dir, migrate_collection, source_path
12
+ from .models import CollectionResult, Post, Profile
13
+ from .storage import PostStore, _atomic_json
14
+ from .transport import now_utc
15
+
16
+ if TYPE_CHECKING:
17
+ from .client import XClient
18
+
19
+
20
+ def _state(path: Path) -> dict:
21
+ return json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
22
+
23
+
24
+ def _posts(path: Path) -> list[Post]:
25
+ if not path.exists():
26
+ return []
27
+ return [
28
+ Post(**json.loads(line))
29
+ for line in path.read_text(encoding="utf-8").split("\n")
30
+ if line.strip()
31
+ ]
32
+
33
+
34
+ def _account_status(output: Path, handle: str, profile: dict) -> dict:
35
+ account_dir = child_dir(output, handle)
36
+ originals = _state(source_path(child_dir(account_dir, "originals"), "state.json"))
37
+ replies = _state(source_path(child_dir(account_dir, "replies"), "state.json"))
38
+ original_count = len(
39
+ _posts(source_path(child_dir(account_dir, "originals"), "posts.jsonl"))
40
+ )
41
+ reply_count = len(
42
+ _posts(source_path(child_dir(account_dir, "replies"), "posts.jsonl"))
43
+ )
44
+ total = len({post.id for post in _posts(source_path(account_dir, "posts.jsonl"))})
45
+ return {
46
+ "handle": handle,
47
+ "user_id": profile["id"],
48
+ "originals": {
49
+ "count": original_count,
50
+ "pages": originals.get("pages_fetched", 0),
51
+ "complete": originals.get("complete", False),
52
+ "reason": originals.get("reason", "not_started"),
53
+ "rate_reset_at": originals.get("rate_reset_at"),
54
+ },
55
+ "replies": {
56
+ "count": reply_count,
57
+ "pages": replies.get("pages_fetched", 0),
58
+ "complete": replies.get("complete", False),
59
+ "reason": replies.get("reason", "not_started"),
60
+ "rate_reset_at": replies.get("rate_reset_at"),
61
+ },
62
+ "unique_posts": total,
63
+ "complete": originals.get("complete", False) and replies.get("complete", False),
64
+ }
65
+
66
+
67
+ def _write_batch_report(
68
+ output: Path, accounts: list[dict], result: CollectionResult
69
+ ) -> None:
70
+ posts = _posts(source_path(output, "posts.jsonl"))
71
+ by_author = Counter(post.author_id for post in posts)
72
+ role_counts = Counter((post.author_id, post.post_role) for post in posts)
73
+ month_counts = Counter((post.author_id, post.created_at_utc[:7]) for post in posts)
74
+ months = sorted({post.created_at_utc[:7] for post in posts})
75
+ lines = [
76
+ "# X 多账号采集报告",
77
+ "",
78
+ f"生成时间(UTC):{now_utc()}",
79
+ f"去重后 **{result.post_count:,}** 条;所有账号分页结束:**{'是' if result.complete else '否'}**({result.reason})。",
80
+ "",
81
+ "| 账号 | 主帖 | 评论/回复 | 转发 | 去重条数 | 浏览量中位数 | Posts状态 | Replies状态 |",
82
+ "| --- | ---: | ---: | ---: | ---: | ---: | --- | --- |",
83
+ ]
84
+ for account in accounts:
85
+ views = [
86
+ post.view_count
87
+ for post in posts
88
+ if post.author_id == account["user_id"] and post.view_count is not None
89
+ ]
90
+ median_views = f"{median(views):,.0f}" if views else "—"
91
+ lines.append(
92
+ f"| [@{account['handle']}](https://x.com/{account['handle']}) "
93
+ f"| {role_counts[(account['user_id'], 'main')]:,} "
94
+ f"| {role_counts[(account['user_id'], 'comment')]:,} "
95
+ f"| {role_counts[(account['user_id'], 'repost')]:,} "
96
+ f"| {by_author[account['user_id']]:,} | {median_views} "
97
+ f"| {account['originals']['reason']} | {account['replies']['reason']} |"
98
+ )
99
+ lines += [
100
+ "",
101
+ "分类按每条帖子自身的回复/引用/转发关系,不按 Posts 或 Replies 标签页来源;两种标签页可能显示同一帖。`main` 包含原创和引用帖,`comment` 是有父帖 ID 的回复。",
102
+ "本次采集按目标账号的作者 ID 筛选,纯转发的原作者不是目标账号,因此未纳入表内;`repost=0` 不代表账号从未转发。",
103
+ "",
104
+ "## 每月可见帖子数(UTC)",
105
+ "",
106
+ "| 月份 | "
107
+ + " | ".join("@" + account["handle"] for account in accounts)
108
+ + " |",
109
+ "| --- | " + " | ".join("---:" for _ in accounts) + " |",
110
+ ]
111
+ for month in months:
112
+ lines.append(
113
+ "| "
114
+ + month
115
+ + " | "
116
+ + " | ".join(
117
+ str(month_counts[(account["user_id"], month)]) for account in accounts
118
+ )
119
+ + " |"
120
+ )
121
+ lines += [
122
+ "",
123
+ "## 覆盖边界",
124
+ "",
125
+ "`passed_since` 表示时间线已越过指定起始日;`source_end` 表示没有下一页;`empty_timeline_end` 表示连续两个只有游标的空页。最后一种是平台分页耗尽信号,不能单独证明历史全量。`rate_limited` 和 `page_limit` 表示尚未完成,可用同一命令续抓。",
126
+ "",
127
+ "删除、受保护、地区限制或未索引的帖子无法据此恢复。互动数是抓取时快照。原始 JSONL、CSV、账号资料、各账号游标及 SHA-256 哈希与本报告一同保存;登录 cookie 不写入输出。",
128
+ "",
129
+ ]
130
+ (internal_dir(output) / "report.md").write_text("\n".join(lines), encoding="utf-8")
131
+
132
+
133
+ def collect_accounts(
134
+ client: "XClient",
135
+ handles: list[str],
136
+ output_dir: str | Path,
137
+ *,
138
+ since: str,
139
+ until: str,
140
+ pages_per_round: int = 5,
141
+ rounds: int = 1,
142
+ wait_on_rate_limit: bool = False,
143
+ progress: Callable[[dict], None] | None = None,
144
+ ) -> CollectionResult:
145
+ """Round-robin collection; rounds=0 continues until complete or stalled."""
146
+ if pages_per_round < 1 or rounds < 0:
147
+ raise ValueError("pages_per_round must be positive and rounds nonnegative")
148
+ if not handles:
149
+ raise ValueError("At least one account is required")
150
+ output = Path(output_dir).expanduser()
151
+ migrate_collection(output)
152
+ saved_profiles = source_path(output, "profiles.json")
153
+ if saved_profiles.exists():
154
+ for handle, record in json.loads(
155
+ saved_profiles.read_text(encoding="utf-8")
156
+ ).items():
157
+ client._profile_cache[handle.lower()] = Profile(**record)
158
+ profiles = {
159
+ client.get_user(handle).handle: client.get_user(handle).to_dict()
160
+ for handle in handles
161
+ }
162
+ canonical = list(dict.fromkeys(profiles))
163
+ _atomic_json(internal_dir(output) / "profiles.json", profiles)
164
+ query = {
165
+ "kind": "account_batch",
166
+ "handles": canonical,
167
+ "since": since,
168
+ "until": until,
169
+ }
170
+ store = PostStore(output, query=query)
171
+ if store.complete:
172
+ result = store.finish(complete=True, reason="all_accounts_complete")
173
+ _write_batch_report(
174
+ output, [_account_status(output, h, profiles[h]) for h in canonical], result
175
+ )
176
+ return result
177
+ completed_rounds = 0
178
+ reason = "round_limit"
179
+ while rounds == 0 or completed_rounds < rounds:
180
+ before = sum(
181
+ _state(
182
+ source_path(child_dir(child_dir(output, h), kind), "state.json")
183
+ ).get("pages_fetched", 0)
184
+ for h in canonical
185
+ for kind in ("originals", "replies")
186
+ )
187
+ for handle in canonical:
188
+ try:
189
+ client.save_user_activity(
190
+ handle,
191
+ child_dir(output, handle),
192
+ since=since,
193
+ until=until,
194
+ max_pages=pages_per_round,
195
+ )
196
+ except PyXcomError as exc:
197
+ if progress:
198
+ progress(
199
+ {
200
+ "account": handle,
201
+ "error": type(exc).__name__,
202
+ "detail": str(exc),
203
+ }
204
+ )
205
+ if progress:
206
+ progress(
207
+ {
208
+ "account": handle,
209
+ **_account_status(output, handle, profiles[handle]),
210
+ }
211
+ )
212
+ accounts = [_account_status(output, h, profiles[h]) for h in canonical]
213
+ collected: dict[str, Post] = {}
214
+ for handle in canonical:
215
+ for post in _posts(source_path(child_dir(output, handle), "posts.jsonl")):
216
+ collected[post.id] = post
217
+ store.append_page(list(collected.values()), None)
218
+ store.state["pages_fetched"] = sum(
219
+ account[kind]["pages"]
220
+ for account in accounts
221
+ for kind in ("originals", "replies")
222
+ )
223
+ completed_rounds += 1
224
+ _atomic_json(
225
+ internal_dir(output) / "account_manifest.json",
226
+ {"query": query, "accounts": accounts, "updated_at_utc": now_utc()},
227
+ )
228
+ checkpoint = store.finish(complete=False, reason="in_progress")
229
+ _write_batch_report(output, accounts, checkpoint)
230
+ if all(account["complete"] for account in accounts):
231
+ reason = "all_accounts_complete"
232
+ break
233
+ after = store.state["pages_fetched"]
234
+ if after <= before:
235
+ rate_states = [
236
+ account[kind]
237
+ for account in accounts
238
+ for kind in ("originals", "replies")
239
+ if account[kind]["reason"] == "rate_limited"
240
+ ]
241
+ if not rate_states:
242
+ reason = "stalled"
243
+ break
244
+ if not wait_on_rate_limit or rounds != 0:
245
+ reason = "rate_limited"
246
+ break
247
+ resets = [
248
+ state["rate_reset_at"]
249
+ for state in rate_states
250
+ if state.get("rate_reset_at") and state["rate_reset_at"] > time.time()
251
+ ]
252
+ wait = max(5, min(resets) - time.time() + 5) if resets else 900
253
+ if progress:
254
+ progress({"status": "rate_limited", "wait_seconds": round(wait)})
255
+ time.sleep(wait)
256
+ complete = reason == "all_accounts_complete"
257
+ result = store.finish(complete=complete, reason=reason)
258
+ accounts = [_account_status(output, h, profiles[h]) for h in canonical]
259
+ _write_batch_report(output, accounts, result)
260
+ return result
pyxcom/cli.py ADDED
@@ -0,0 +1,300 @@
1
+ """Command-line entry point for pyXcom."""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from .client import XClient
9
+ from .layout import internal_dir
10
+ from .errors import PyXcomError
11
+ from .schema import apply_role_schema, write_schema_report
12
+ from .tables import export_tables
13
+ from .validate import finalize_collection, validate_collection
14
+
15
+
16
+ def _common_parser() -> argparse.ArgumentParser:
17
+ common = argparse.ArgumentParser(add_help=False)
18
+ common.add_argument("--browser", choices=("chrome", "edge"), default="chrome")
19
+ common.add_argument("--profile", help="Browser profile folder, e.g. 'Profile 3'")
20
+ common.add_argument(
21
+ "--cookie-db", type=Path, help="Explicit Chrome/Edge Cookies DB path"
22
+ )
23
+ common.add_argument(
24
+ "--proxy", help="HTTP or SOCKS proxy URL for X and mirror requests"
25
+ )
26
+ common.add_argument(
27
+ "--mirror-base",
28
+ help="Explicit HTTPS mirror for cross-user search; sends search terms to that mirror",
29
+ )
30
+ common.add_argument(
31
+ "--delay", type=float, default=1.0, help="Seconds between pages"
32
+ )
33
+ return common
34
+
35
+
36
+ def build_parser() -> argparse.ArgumentParser:
37
+ parser = argparse.ArgumentParser(prog="pyxcom", description="Collect public X data")
38
+ commands = parser.add_subparsers(dest="command", required=True)
39
+ common = _common_parser()
40
+ profile = commands.add_parser(
41
+ "user", aliases=["profile"], parents=[common], help="Get public user profile"
42
+ )
43
+ profile.set_defaults(command="profile")
44
+ profile.add_argument("handle")
45
+ profile.add_argument("--output", type=Path)
46
+ post = commands.add_parser("post", parents=[common], help="Get one public post")
47
+ post.add_argument("post_id_or_url")
48
+ post.add_argument("--output", type=Path)
49
+ raw = commands.add_parser(
50
+ "raw-post", parents=[common], help="Get raw public X JSON"
51
+ )
52
+ raw.add_argument("post_id_or_url")
53
+ raw.add_argument("--output", type=Path)
54
+ posts = commands.add_parser(
55
+ "user-posts",
56
+ aliases=["posts"],
57
+ parents=[common],
58
+ help="Save authored main posts",
59
+ )
60
+ posts.set_defaults(command="posts")
61
+ posts.add_argument("handle")
62
+ posts.add_argument("--timeline", choices=("posts", "replies"), default="posts")
63
+ posts.add_argument("--since", help="Inclusive UTC date YYYY-MM-DD")
64
+ posts.add_argument("--until", help="Exclusive UTC date YYYY-MM-DD")
65
+ posts.add_argument("--max-pages", type=int)
66
+ posts.add_argument("--limit", type=int)
67
+ posts.add_argument(
68
+ "--output-dir", "--output", dest="output", type=Path, required=True
69
+ )
70
+ activity = commands.add_parser(
71
+ "user-activity",
72
+ aliases=["activity"],
73
+ parents=[common],
74
+ help="Save posts and authored replies",
75
+ )
76
+ activity.set_defaults(command="activity")
77
+ activity.add_argument("handle")
78
+ activity.add_argument("--since")
79
+ activity.add_argument("--until")
80
+ activity.add_argument("--max-pages", type=int)
81
+ activity.add_argument(
82
+ "--output-dir", "--output", dest="output", type=Path, required=True
83
+ )
84
+ search = commands.add_parser(
85
+ "search-posts",
86
+ aliases=["search"],
87
+ parents=[common],
88
+ help="Search keyword, dates, and user",
89
+ )
90
+ search.set_defaults(command="search")
91
+ search.add_argument("keyword", help="Literal word or phrase for direct user search")
92
+ search.add_argument(
93
+ "--handle", "--user", dest="user", help="Author handle for direct X search"
94
+ )
95
+ search.add_argument("--since", help="Inclusive UTC date YYYY-MM-DD")
96
+ search.add_argument("--until", help="Exclusive UTC date YYYY-MM-DD")
97
+ search.add_argument("--max-pages", type=int)
98
+ search.add_argument("--limit", type=int)
99
+ search.add_argument(
100
+ "--output-dir", "--output", dest="output", type=Path, required=True
101
+ )
102
+ batch = commands.add_parser(
103
+ "users-activity",
104
+ aliases=["batch"],
105
+ parents=[common],
106
+ help="Collect several accounts and write a combined report",
107
+ )
108
+ batch.set_defaults(command="batch")
109
+ batch.add_argument("--handles", nargs="+", required=True)
110
+ batch.add_argument("--since", required=True)
111
+ batch.add_argument("--until", required=True)
112
+ batch.add_argument("--pages-per-round", type=int, default=5)
113
+ batch.add_argument(
114
+ "--rounds", type=int, default=1, help="0 keeps resuming until complete"
115
+ )
116
+ batch.add_argument("--wait-on-rate-limit", action="store_true")
117
+ batch.add_argument(
118
+ "--output-dir", "--output", dest="output", type=Path, required=True
119
+ )
120
+ validate = commands.add_parser(
121
+ "validate", help="Check saved rows, scope, and SHA-256 hashes"
122
+ )
123
+ validate.add_argument(
124
+ "--output-dir", "--output", dest="output", type=Path, required=True
125
+ )
126
+ finalize = commands.add_parser(
127
+ "finalize", help="Rebuild CSV and report after an interrupted run"
128
+ )
129
+ finalize.add_argument(
130
+ "--output-dir", "--output", dest="output", type=Path, required=True
131
+ )
132
+ schema = commands.add_parser(
133
+ "schema", help="Report field coverage and optionally add main/comment columns"
134
+ )
135
+ schema.add_argument(
136
+ "--output-dir", "--output", dest="output", type=Path, required=True
137
+ )
138
+ schema.add_argument(
139
+ "--apply", action="store_true", help="Backfill role/type in saved CSV and JSONL"
140
+ )
141
+ tables = commands.add_parser(
142
+ "export", help="Export saved records as users/posts/comments relational tables"
143
+ )
144
+ tables.add_argument(
145
+ "--output-dir", "--output", dest="output", type=Path, required=True
146
+ )
147
+ replies = commands.add_parser(
148
+ "user-replies", parents=[common], help="Save replies authored by an account"
149
+ )
150
+ replies.set_defaults(command="posts", timeline="replies", limit=None)
151
+ replies.add_argument("handle")
152
+ replies.add_argument("--since")
153
+ replies.add_argument("--until")
154
+ replies.add_argument("--max-pages", type=int)
155
+ replies.add_argument("--limit", type=int)
156
+ replies.add_argument(
157
+ "--output-dir", "--output", dest="output", type=Path, required=True
158
+ )
159
+ comments = commands.add_parser(
160
+ "post-comments",
161
+ parents=[common],
162
+ help="Collect replies below one main post, including nested replies",
163
+ )
164
+ comments.add_argument("post_id_or_url")
165
+ comments.add_argument("--max-depth", type=int, default=2)
166
+ comments.add_argument("--max-comments", type=int, default=100)
167
+ comments.add_argument("--max-pages", type=int, default=20)
168
+ comments.add_argument(
169
+ "--output-dir", "--output", dest="output", type=Path, required=True
170
+ )
171
+ return parser
172
+
173
+
174
+ def _write_or_print(value: dict, output: Path | None) -> None:
175
+ text = json.dumps(value, ensure_ascii=False, indent=2)
176
+ if output is None:
177
+ print(text)
178
+ else:
179
+ output.parent.mkdir(parents=True, exist_ok=True)
180
+ output.write_text(text + "\n", encoding="utf-8")
181
+ print(str(output))
182
+
183
+
184
+ def main(argv: list[str] | None = None) -> int:
185
+ parser = build_parser()
186
+ args = parser.parse_args(argv)
187
+ try:
188
+ if args.command == "export":
189
+ print(json.dumps(export_tables(args.output), ensure_ascii=False))
190
+ return 0
191
+ if args.command == "validate":
192
+ validation = validate_collection(args.output)
193
+ print(json.dumps(validation, ensure_ascii=False))
194
+ return 0 if validation["valid"] else 3
195
+ if args.command == "finalize":
196
+ result = finalize_collection(args.output)
197
+ print(json.dumps(result.to_dict(), ensure_ascii=False))
198
+ return 0
199
+ if args.command == "schema":
200
+ summary = (
201
+ apply_role_schema(args.output)
202
+ if args.apply
203
+ else write_schema_report(args.output)
204
+ )
205
+ print(
206
+ json.dumps(
207
+ {
208
+ "post_count": summary["post_count"],
209
+ "post_field_count": summary["post_field_count"],
210
+ "role_counts": summary["role_counts"],
211
+ "type_counts": summary["type_counts"],
212
+ "schema_report": str(
213
+ internal_dir(args.output) / "schema_report.md"
214
+ ),
215
+ "changed_directories": len(
216
+ summary.get("changed_directories", [])
217
+ ),
218
+ },
219
+ ensure_ascii=False,
220
+ )
221
+ )
222
+ return 0
223
+ with XClient(
224
+ browser=args.browser,
225
+ profile=args.profile,
226
+ cookie_db=args.cookie_db,
227
+ proxy=args.proxy,
228
+ mirror_base=args.mirror_base,
229
+ delay=args.delay,
230
+ ) as client:
231
+ if args.command == "post-comments":
232
+ result = client.save_post_comments(
233
+ args.post_id_or_url,
234
+ args.output,
235
+ max_depth=args.max_depth,
236
+ max_comments=args.max_comments,
237
+ max_pages=args.max_pages,
238
+ )
239
+ print(json.dumps(result.to_dict(), ensure_ascii=False))
240
+ elif args.command == "profile":
241
+ _write_or_print(client.get_user(args.handle).to_dict(), args.output)
242
+ elif args.command == "post":
243
+ _write_or_print(
244
+ client.get_post(args.post_id_or_url).to_dict(), args.output
245
+ )
246
+ elif args.command == "raw-post":
247
+ _write_or_print(client.get_raw_post(args.post_id_or_url), args.output)
248
+ elif args.command == "posts":
249
+ result = client.save_user_posts(
250
+ args.handle,
251
+ args.output,
252
+ timeline=args.timeline,
253
+ since=args.since,
254
+ until=args.until,
255
+ max_pages=args.max_pages,
256
+ limit=args.limit,
257
+ )
258
+ print(json.dumps(result.to_dict(), ensure_ascii=False))
259
+ elif args.command == "activity":
260
+ result = client.save_user_activity(
261
+ args.handle,
262
+ args.output,
263
+ since=args.since,
264
+ until=args.until,
265
+ max_pages=args.max_pages,
266
+ )
267
+ print(json.dumps(result.to_dict(), ensure_ascii=False))
268
+ elif args.command == "search":
269
+ result = client.save_search_posts(
270
+ args.keyword,
271
+ args.output,
272
+ handle=args.user,
273
+ since=args.since,
274
+ until=args.until,
275
+ max_pages=args.max_pages,
276
+ limit=args.limit,
277
+ )
278
+ print(json.dumps(result.to_dict(), ensure_ascii=False))
279
+ elif args.command == "batch":
280
+ result = client.save_users_activity(
281
+ args.handles,
282
+ args.output,
283
+ since=args.since,
284
+ until=args.until,
285
+ pages_per_round=args.pages_per_round,
286
+ rounds=args.rounds,
287
+ wait_on_rate_limit=args.wait_on_rate_limit,
288
+ progress=lambda item: print(
289
+ json.dumps(item, ensure_ascii=False), flush=True
290
+ ),
291
+ )
292
+ print(json.dumps(result.to_dict(), ensure_ascii=False))
293
+ except (PyXcomError, ValueError, OSError) as exc:
294
+ print(f"pyxcom: {exc}", file=sys.stderr)
295
+ return 2
296
+ return 0
297
+
298
+
299
+ if __name__ == "__main__":
300
+ raise SystemExit(main())