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/parse.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Parse X's public GraphQL response objects without depending on twikit."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Any, Iterator
|
|
5
|
+
|
|
6
|
+
from .errors import ParseError
|
|
7
|
+
from .models import Post, Profile
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _walk(value: Any) -> Iterator[dict]:
|
|
11
|
+
if isinstance(value, dict):
|
|
12
|
+
yield value
|
|
13
|
+
for child in value.values():
|
|
14
|
+
yield from _walk(child)
|
|
15
|
+
elif isinstance(value, list):
|
|
16
|
+
for child in value:
|
|
17
|
+
yield from _walk(child)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _count(value: Any) -> int | None:
|
|
21
|
+
if value is None or value == "":
|
|
22
|
+
return None
|
|
23
|
+
try:
|
|
24
|
+
return int(value)
|
|
25
|
+
except (TypeError, ValueError):
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _utc(value: str | None) -> str | None:
|
|
30
|
+
if not value:
|
|
31
|
+
return None
|
|
32
|
+
try:
|
|
33
|
+
return (
|
|
34
|
+
datetime.strptime(value, "%a %b %d %H:%M:%S %z %Y")
|
|
35
|
+
.astimezone(timezone.utc)
|
|
36
|
+
.isoformat()
|
|
37
|
+
)
|
|
38
|
+
except ValueError:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_profile(data: dict, *, captured_at_utc: str | None = None) -> Profile:
|
|
43
|
+
"""Parse a UserByScreenName result or its inner user result."""
|
|
44
|
+
result = data.get("data", {}).get("user", {}).get("result", data)
|
|
45
|
+
legacy = result.get("legacy") or {}
|
|
46
|
+
core = result.get("core") or {}
|
|
47
|
+
relationships = result.get("relationship_counts") or {}
|
|
48
|
+
tweet_counts = result.get("tweet_counts") or {}
|
|
49
|
+
user_id = result.get("rest_id")
|
|
50
|
+
handle = legacy.get("screen_name") or core.get("screen_name")
|
|
51
|
+
if not user_id or not handle:
|
|
52
|
+
raise ParseError("X user response has no user ID or screen name")
|
|
53
|
+
return Profile(
|
|
54
|
+
id=str(user_id),
|
|
55
|
+
handle=handle,
|
|
56
|
+
name=legacy.get("name") or core.get("name") or handle,
|
|
57
|
+
description=legacy.get("description")
|
|
58
|
+
or (result.get("profile_bio") or {}).get("description"),
|
|
59
|
+
created_at_utc=_utc(legacy.get("created_at") or core.get("created_at")),
|
|
60
|
+
followers_count=_count(
|
|
61
|
+
legacy.get("followers_count", relationships.get("followers"))
|
|
62
|
+
),
|
|
63
|
+
following_count=_count(
|
|
64
|
+
legacy.get("friends_count", relationships.get("following"))
|
|
65
|
+
),
|
|
66
|
+
posts_count=_count(legacy.get("statuses_count", tweet_counts.get("tweets"))),
|
|
67
|
+
media_count=_count(legacy.get("media_count", tweet_counts.get("media_tweets"))),
|
|
68
|
+
verified=bool(result.get("is_blue_verified") or legacy.get("verified")),
|
|
69
|
+
url=f"https://x.com/{handle}",
|
|
70
|
+
captured_at_utc=captured_at_utc,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parse_post(data: dict, *, captured_at_utc: str | None = None) -> Post | None:
|
|
75
|
+
"""Parse a Tweet or TweetWithVisibilityResults result."""
|
|
76
|
+
if data.get("__typename") == "TweetTombstone":
|
|
77
|
+
return None
|
|
78
|
+
if "tweet" in data:
|
|
79
|
+
data = data["tweet"]
|
|
80
|
+
legacy = data.get("legacy") or {}
|
|
81
|
+
post_id = data.get("rest_id") or legacy.get("id_str")
|
|
82
|
+
user_result = data.get("core", {}).get("user_results", {}).get("result") or {}
|
|
83
|
+
author = user_result.get("legacy") or user_result.get("core") or {}
|
|
84
|
+
author_id = legacy.get("user_id_str") or user_result.get("rest_id")
|
|
85
|
+
created = _utc(legacy.get("created_at"))
|
|
86
|
+
if not post_id or not author_id or not created:
|
|
87
|
+
return None
|
|
88
|
+
handle = author.get("screen_name") or ""
|
|
89
|
+
note = (
|
|
90
|
+
(data.get("note_tweet") or {}).get("note_tweet_results", {}).get("result", {})
|
|
91
|
+
)
|
|
92
|
+
text = note.get("text") or legacy.get("full_text") or ""
|
|
93
|
+
entities = legacy.get("entities") or {}
|
|
94
|
+
extended = legacy.get("extended_entities") or {}
|
|
95
|
+
media_urls: list[str] = []
|
|
96
|
+
for media in extended.get("media", entities.get("media", [])):
|
|
97
|
+
if media.get("media_url_https"):
|
|
98
|
+
media_urls.append(media["media_url_https"])
|
|
99
|
+
variants = (media.get("video_info") or {}).get("variants") or []
|
|
100
|
+
mp4 = [
|
|
101
|
+
variant
|
|
102
|
+
for variant in variants
|
|
103
|
+
if variant.get("content_type") == "video/mp4"
|
|
104
|
+
]
|
|
105
|
+
if mp4:
|
|
106
|
+
best = max(mp4, key=lambda variant: variant.get("bitrate") or 0)
|
|
107
|
+
if best.get("url"):
|
|
108
|
+
media_urls.append(best["url"])
|
|
109
|
+
outbound = [
|
|
110
|
+
item["expanded_url"]
|
|
111
|
+
for item in entities.get("urls", [])
|
|
112
|
+
if item.get("expanded_url")
|
|
113
|
+
]
|
|
114
|
+
hashtags = [
|
|
115
|
+
item["text"] for item in entities.get("hashtags", []) if item.get("text")
|
|
116
|
+
]
|
|
117
|
+
mentions = [
|
|
118
|
+
item["screen_name"]
|
|
119
|
+
for item in entities.get("user_mentions", [])
|
|
120
|
+
if item.get("screen_name")
|
|
121
|
+
]
|
|
122
|
+
repost_result = legacy.get("retweeted_status_result") or {}
|
|
123
|
+
return Post(
|
|
124
|
+
id=str(post_id),
|
|
125
|
+
author_id=str(author_id),
|
|
126
|
+
author_handle=handle,
|
|
127
|
+
created_at_utc=created,
|
|
128
|
+
text=text,
|
|
129
|
+
url=f"https://x.com/{handle}/status/{post_id}"
|
|
130
|
+
if handle
|
|
131
|
+
else f"https://x.com/i/status/{post_id}",
|
|
132
|
+
language=legacy.get("lang"),
|
|
133
|
+
conversation_id=legacy.get("conversation_id_str"),
|
|
134
|
+
in_reply_to_id=legacy.get("in_reply_to_status_id_str"),
|
|
135
|
+
quoted_post_id=legacy.get("quoted_status_id_str"),
|
|
136
|
+
reposted_post_id=(repost_result.get("result") or {}).get("rest_id"),
|
|
137
|
+
reply_count=_count(legacy.get("reply_count")),
|
|
138
|
+
repost_count=_count(legacy.get("retweet_count")),
|
|
139
|
+
like_count=_count(legacy.get("favorite_count")),
|
|
140
|
+
view_count=_count((data.get("views") or {}).get("count")),
|
|
141
|
+
quote_count=_count(legacy.get("quote_count")),
|
|
142
|
+
bookmark_count=_count(legacy.get("bookmark_count")),
|
|
143
|
+
media_urls=list(dict.fromkeys(media_urls)),
|
|
144
|
+
outbound_urls=list(dict.fromkeys(outbound)),
|
|
145
|
+
hashtags=list(dict.fromkeys(hashtags)),
|
|
146
|
+
mentions=list(dict.fromkeys(mentions)),
|
|
147
|
+
captured_at_utc=captured_at_utc,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def timeline_posts(data: dict, *, captured_at_utc: str | None = None) -> list[Post]:
|
|
152
|
+
"""Read tweet_results in user, search, or conversation timelines."""
|
|
153
|
+
posts: dict[str, Post] = {}
|
|
154
|
+
for node in _walk(data.get("data", data)):
|
|
155
|
+
tweet_results = node.get("tweet_results")
|
|
156
|
+
if not isinstance(tweet_results, dict):
|
|
157
|
+
continue
|
|
158
|
+
result = tweet_results.get("result")
|
|
159
|
+
if not isinstance(result, dict):
|
|
160
|
+
continue
|
|
161
|
+
post = parse_post(result, captured_at_utc=captured_at_utc)
|
|
162
|
+
if post is not None:
|
|
163
|
+
posts[post.id] = post
|
|
164
|
+
return list(posts.values())
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def timeline_primary_posts(
|
|
168
|
+
data: dict, *, captured_at_utc: str | None = None
|
|
169
|
+
) -> list[Post]:
|
|
170
|
+
"""Read timeline entries without recursively counting quoted post content."""
|
|
171
|
+
instructions = None
|
|
172
|
+
for node in _walk(data.get("data", data)):
|
|
173
|
+
if isinstance(node.get("instructions"), list):
|
|
174
|
+
instructions = node["instructions"]
|
|
175
|
+
break
|
|
176
|
+
if instructions is None:
|
|
177
|
+
return []
|
|
178
|
+
posts: dict[str, Post] = {}
|
|
179
|
+
for instruction in instructions:
|
|
180
|
+
for entry in instruction.get("entries", []):
|
|
181
|
+
content = entry.get("content") or {}
|
|
182
|
+
items = [content]
|
|
183
|
+
for module_item in content.get("items", []):
|
|
184
|
+
items.append(module_item.get("item") or module_item)
|
|
185
|
+
for item in items:
|
|
186
|
+
result = (
|
|
187
|
+
(item.get("itemContent") or {})
|
|
188
|
+
.get("tweet_results", {})
|
|
189
|
+
.get("result")
|
|
190
|
+
)
|
|
191
|
+
if isinstance(result, dict):
|
|
192
|
+
post = parse_post(result, captured_at_utc=captured_at_utc)
|
|
193
|
+
if post is not None:
|
|
194
|
+
posts[post.id] = post
|
|
195
|
+
return list(posts.values())
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def bottom_cursor(data: dict) -> str | None:
|
|
199
|
+
"""Find the bottom pagination cursor without relying on one timeline shape."""
|
|
200
|
+
for node in _walk(data.get("data", data)):
|
|
201
|
+
entry_id = node.get("entryId", "")
|
|
202
|
+
content = node.get("content") or {}
|
|
203
|
+
if isinstance(entry_id, str) and entry_id.startswith("cursor-bottom"):
|
|
204
|
+
return content.get("value")
|
|
205
|
+
if content.get("cursorType") == "Bottom":
|
|
206
|
+
return content.get("value")
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def single_post(data: dict, *, captured_at_utc: str | None = None) -> Post:
|
|
211
|
+
result = data.get("data", {}).get("tweetResult", {}).get("result")
|
|
212
|
+
if not isinstance(result, dict):
|
|
213
|
+
raise ParseError("X single-post response has no tweet result")
|
|
214
|
+
post = parse_post(result, captured_at_utc=captured_at_utc)
|
|
215
|
+
if post is None:
|
|
216
|
+
raise ParseError("X post is unavailable or its response changed")
|
|
217
|
+
return post
|
pyxcom/schema.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Post-role classification and field coverage reports for saved datasets."""
|
|
2
|
+
|
|
3
|
+
import gzip
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from dataclasses import fields
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .layout import internal_dir, migrate_collection, source_path
|
|
11
|
+
from .models import Post, Profile
|
|
12
|
+
from .storage import PostStore, _atomic_json
|
|
13
|
+
from .validate import finalize_collection, validate_collection
|
|
14
|
+
|
|
15
|
+
FIELD_GROUPS = {
|
|
16
|
+
"id": ("身份", "X 帖子 ID;以字符串保存"),
|
|
17
|
+
"author_id": ("身份", "作者 X ID"),
|
|
18
|
+
"author_handle": ("身份", "作者账号名"),
|
|
19
|
+
"url": ("身份", "帖子永久链接"),
|
|
20
|
+
"created_at_utc": ("时间与正文", "发布时间,UTC ISO 8601"),
|
|
21
|
+
"text": ("时间与正文", "完整可取得的正文"),
|
|
22
|
+
"language": ("时间与正文", "X 返回的语言代码"),
|
|
23
|
+
"post_role": ("分类", "main 主帖 / comment 评论回复 / repost 转发"),
|
|
24
|
+
"post_type": ("分类", "original / quote / reply / repost"),
|
|
25
|
+
"conversation_id": ("关系", "会话根帖 ID"),
|
|
26
|
+
"in_reply_to_id": ("关系", "直接回复的父帖 ID"),
|
|
27
|
+
"quoted_post_id": ("关系", "被引用帖子 ID"),
|
|
28
|
+
"reposted_post_id": ("关系", "被转发帖子 ID"),
|
|
29
|
+
"reply_count": ("互动", "收到的回复数"),
|
|
30
|
+
"repost_count": ("互动", "转发数"),
|
|
31
|
+
"like_count": ("互动", "点赞数"),
|
|
32
|
+
"view_count": ("互动", "公开浏览量"),
|
|
33
|
+
"quote_count": ("互动", "被引用次数"),
|
|
34
|
+
"bookmark_count": ("互动", "书签数"),
|
|
35
|
+
"media_urls": ("内容元素", "图片、视频预览或视频链接数组"),
|
|
36
|
+
"outbound_urls": ("内容元素", "正文外链数组"),
|
|
37
|
+
"hashtags": ("内容元素", "话题标签数组"),
|
|
38
|
+
"mentions": ("内容元素", "提及账号数组"),
|
|
39
|
+
"discovery_source": ("来源", "发现帖子 ID 的方式"),
|
|
40
|
+
"discovery_url": ("来源", "发现页面链接"),
|
|
41
|
+
"captured_at_utc": ("来源", "该条响应的抓取时间;旧数据可能为空"),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
RAW_OPTIONAL = [
|
|
45
|
+
(
|
|
46
|
+
"in_reply_to_user_id_str / in_reply_to_screen_name",
|
|
47
|
+
"回复目标账号;当前总表尚未标准化",
|
|
48
|
+
),
|
|
49
|
+
(
|
|
50
|
+
"extended_entities.media[].type / sizes / video_info",
|
|
51
|
+
"媒体类型、尺寸、视频信息;当前只导出链接",
|
|
52
|
+
),
|
|
53
|
+
("possibly_sensitive", "X 的敏感内容标记;并非研究者自定义编码"),
|
|
54
|
+
("source / display_text_range", "客户端来源与显示文本范围"),
|
|
55
|
+
(
|
|
56
|
+
"quoted_status_result / note_tweet",
|
|
57
|
+
"引用帖嵌套对象及长文结构;正文已优先取长文文本",
|
|
58
|
+
),
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _read_posts(path: Path) -> list[Post]:
|
|
63
|
+
return [
|
|
64
|
+
Post(**json.loads(line))
|
|
65
|
+
for line in path.read_text(encoding="utf-8").split("\n")
|
|
66
|
+
if line.strip()
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def schema_summary(output_dir: str | Path) -> dict:
|
|
71
|
+
output = Path(output_dir).expanduser()
|
|
72
|
+
posts = _read_posts(source_path(output, "posts.jsonl"))
|
|
73
|
+
post_fields = []
|
|
74
|
+
for item in fields(Post):
|
|
75
|
+
values = [getattr(post, item.name) for post in posts]
|
|
76
|
+
available = sum(value is not None and value != "" for value in values)
|
|
77
|
+
nonempty = sum(bool(value) for value in values if isinstance(value, list))
|
|
78
|
+
group, description = FIELD_GROUPS[item.name]
|
|
79
|
+
post_fields.append(
|
|
80
|
+
{
|
|
81
|
+
"name": item.name,
|
|
82
|
+
"group": group,
|
|
83
|
+
"description": description,
|
|
84
|
+
"available": available,
|
|
85
|
+
"missing": len(posts) - available,
|
|
86
|
+
"nonempty_array": nonempty
|
|
87
|
+
if any(isinstance(v, list) for v in values)
|
|
88
|
+
else None,
|
|
89
|
+
"example_type": next(
|
|
90
|
+
(type(v).__name__ for v in values if v is not None), "None"
|
|
91
|
+
),
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
profiles_path = source_path(output, "profiles.json")
|
|
95
|
+
profiles = (
|
|
96
|
+
[Profile(**value) for value in json.loads(profiles_path.read_text()).values()]
|
|
97
|
+
if profiles_path.exists()
|
|
98
|
+
else []
|
|
99
|
+
)
|
|
100
|
+
profile_fields = [
|
|
101
|
+
{
|
|
102
|
+
"name": item.name,
|
|
103
|
+
"available": sum(
|
|
104
|
+
getattr(profile, item.name) is not None for profile in profiles
|
|
105
|
+
),
|
|
106
|
+
"missing": len(profiles)
|
|
107
|
+
- sum(getattr(profile, item.name) is not None for profile in profiles),
|
|
108
|
+
}
|
|
109
|
+
for item in fields(Profile)
|
|
110
|
+
]
|
|
111
|
+
roles = Counter(post.post_role for post in posts)
|
|
112
|
+
kinds = Counter(post.post_type for post in posts)
|
|
113
|
+
by_account = {}
|
|
114
|
+
for handle in sorted({post.author_handle for post in posts}):
|
|
115
|
+
own = [post for post in posts if post.author_handle == handle]
|
|
116
|
+
by_account[handle] = {
|
|
117
|
+
"total": len(own),
|
|
118
|
+
"roles": dict(Counter(post.post_role for post in own)),
|
|
119
|
+
"types": dict(Counter(post.post_type for post in own)),
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
"output_dir": str(output),
|
|
123
|
+
"post_count": len(posts),
|
|
124
|
+
"post_field_count": len(post_fields),
|
|
125
|
+
"role_counts": dict(roles),
|
|
126
|
+
"type_counts": dict(kinds),
|
|
127
|
+
"by_account": by_account,
|
|
128
|
+
"post_fields": post_fields,
|
|
129
|
+
"profile_count": len(profiles),
|
|
130
|
+
"profile_fields": profile_fields,
|
|
131
|
+
"raw_optional_fields": RAW_OPTIONAL,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def write_schema_report(output_dir: str | Path) -> dict:
|
|
136
|
+
output = Path(output_dir).expanduser()
|
|
137
|
+
summary = schema_summary(output)
|
|
138
|
+
internal_dir(output).mkdir(parents=True, exist_ok=True)
|
|
139
|
+
_atomic_json(internal_dir(output) / "schema.json", summary)
|
|
140
|
+
lines = [
|
|
141
|
+
"# pyXcom 数据字段与主帖/评论分类",
|
|
142
|
+
"",
|
|
143
|
+
f"当前去重帖子:**{summary['post_count']:,}** 条;标准化帖子字段:**{summary['post_field_count']}** 个。",
|
|
144
|
+
"",
|
|
145
|
+
"## 分类规则",
|
|
146
|
+
"",
|
|
147
|
+
"- `reposted_post_id` 有值 → `post_role=repost`、`post_type=repost`。",
|
|
148
|
+
"- 否则 `in_reply_to_id` 有值 → `post_role=comment`、`post_type=reply`。回复自己的帖也按评论/回复记录。",
|
|
149
|
+
"- 否则 `quoted_post_id` 有值 → `post_role=main`、`post_type=quote`。",
|
|
150
|
+
"- 其余 → `post_role=main`、`post_type=original`。若多种关系同时存在,分类优先级为转发、回复、引用。原始关系 ID 保留在各列。",
|
|
151
|
+
"- 本次六账号数据只收作者本人生成的帖子;纯转发通常显示原作者 ID,未被纳入。因此此处 `repost=0` 是采集口径,不代表这些账号从未转发。",
|
|
152
|
+
"",
|
|
153
|
+
"| 主帖/评论角色 | 条数 |",
|
|
154
|
+
"| --- | ---: |",
|
|
155
|
+
]
|
|
156
|
+
lines += [
|
|
157
|
+
f"| {key} | {value:,} |"
|
|
158
|
+
for key, value in sorted(summary["role_counts"].items())
|
|
159
|
+
]
|
|
160
|
+
lines += ["", "| 帖子类型 | 条数 |", "| --- | ---: |"]
|
|
161
|
+
lines += [
|
|
162
|
+
f"| {key} | {value:,} |"
|
|
163
|
+
for key, value in sorted(summary["type_counts"].items())
|
|
164
|
+
]
|
|
165
|
+
lines += [
|
|
166
|
+
"",
|
|
167
|
+
"## 各账号分类",
|
|
168
|
+
"",
|
|
169
|
+
"| 账号 | 主帖 | 评论/回复 | 转发 | 合计 |",
|
|
170
|
+
"| --- | ---: | ---: | ---: | ---: |",
|
|
171
|
+
]
|
|
172
|
+
for handle, value in summary["by_account"].items():
|
|
173
|
+
roles = value["roles"]
|
|
174
|
+
lines.append(
|
|
175
|
+
f"| @{handle} | {roles.get('main', 0):,} | {roles.get('comment', 0):,} "
|
|
176
|
+
f"| {roles.get('repost', 0):,} | {value['total']:,} |"
|
|
177
|
+
)
|
|
178
|
+
lines += [
|
|
179
|
+
"",
|
|
180
|
+
"## 当前帖子表的字段覆盖",
|
|
181
|
+
"",
|
|
182
|
+
"| 分组 | 字段 | 含义 | 有值条数 | 空值条数 | 非空数组条数 |",
|
|
183
|
+
"| --- | --- | --- | ---: | ---: | ---: |",
|
|
184
|
+
]
|
|
185
|
+
for item in summary["post_fields"]:
|
|
186
|
+
lines.append(
|
|
187
|
+
f"| {item['group']} | `{item['name']}` | {item['description']} "
|
|
188
|
+
f"| {item['available']:,} | {item['missing']:,} "
|
|
189
|
+
f"| {item['nonempty_array'] if item['nonempty_array'] is not None else '—'} |"
|
|
190
|
+
)
|
|
191
|
+
lines += ["", "## 账号资料字段", ""]
|
|
192
|
+
lines.append(", ".join(f"`{item['name']}`" for item in summary["profile_fields"]))
|
|
193
|
+
lines += [
|
|
194
|
+
"",
|
|
195
|
+
"## X 原始返回中还可能取得、目前尚未标准化的字段",
|
|
196
|
+
"",
|
|
197
|
+
"| 原始字段 | 说明 |",
|
|
198
|
+
"| --- | --- |",
|
|
199
|
+
]
|
|
200
|
+
lines += [f"| `{name}` | {description} |" for name, description in RAW_OPTIONAL]
|
|
201
|
+
lines += [
|
|
202
|
+
"",
|
|
203
|
+
"这些可选字段依帖子类型和 X 当前返回格式而异;不能把不存在的字段当作零。登录账号自己的 `favorited`、`bookmarked` 等状态不是公开互动总数,不建议混入公共帖子表。",
|
|
204
|
+
"",
|
|
205
|
+
"CSV 中数组字段是 JSON 字符串;JSONL 中保留数组、整数及 null。`captured_at_utc` 的旧记录为空时,不能用批次导出时间冒充每条帖子的抓取时间。",
|
|
206
|
+
"",
|
|
207
|
+
]
|
|
208
|
+
(internal_dir(output) / "schema_report.md").write_text(
|
|
209
|
+
"\n".join(lines), encoding="utf-8"
|
|
210
|
+
)
|
|
211
|
+
return summary
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def apply_role_schema(output_dir: str | Path) -> dict:
|
|
215
|
+
"""Backfill role/type in saved JSONL; preserve gzipped originals once."""
|
|
216
|
+
output = Path(output_dir).expanduser()
|
|
217
|
+
migrate_collection(output)
|
|
218
|
+
changed: list[str] = []
|
|
219
|
+
paths = sorted(
|
|
220
|
+
(
|
|
221
|
+
p
|
|
222
|
+
for p in output.rglob("posts.jsonl")
|
|
223
|
+
if "legacy" not in p.relative_to(output).parts
|
|
224
|
+
),
|
|
225
|
+
key=lambda path: len(path.parts),
|
|
226
|
+
reverse=True,
|
|
227
|
+
)
|
|
228
|
+
for path in paths:
|
|
229
|
+
folder = path.parent.parent if path.parent.name == ".pyxcom" else path.parent
|
|
230
|
+
state_path = source_path(folder, "state.json")
|
|
231
|
+
if not state_path.exists():
|
|
232
|
+
continue
|
|
233
|
+
raw = [
|
|
234
|
+
json.loads(line)
|
|
235
|
+
for line in path.read_text(encoding="utf-8").split("\n")
|
|
236
|
+
if line.strip()
|
|
237
|
+
]
|
|
238
|
+
posts = [Post(**record) for record in raw]
|
|
239
|
+
if all(
|
|
240
|
+
record.get("post_role") == post.post_role
|
|
241
|
+
and record.get("post_type") == post.post_type
|
|
242
|
+
for record, post in zip(raw, posts)
|
|
243
|
+
):
|
|
244
|
+
continue
|
|
245
|
+
backup = path.parent / "posts.jsonl.before-role-schema.gz"
|
|
246
|
+
if not backup.exists():
|
|
247
|
+
with gzip.open(backup, "wb") as file:
|
|
248
|
+
file.write(path.read_bytes())
|
|
249
|
+
temporary = path.with_suffix(".jsonl.tmp")
|
|
250
|
+
with temporary.open("w", encoding="utf-8") as file:
|
|
251
|
+
for post in posts:
|
|
252
|
+
file.write(json.dumps(post.to_dict(), ensure_ascii=True) + "\n")
|
|
253
|
+
os.replace(temporary, path)
|
|
254
|
+
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
255
|
+
store = PostStore(folder, query=state["query"])
|
|
256
|
+
store.finish(
|
|
257
|
+
complete=state.get("complete", False),
|
|
258
|
+
reason=state.get("reason", "migrated"),
|
|
259
|
+
)
|
|
260
|
+
changed.append(str(folder))
|
|
261
|
+
if source_path(output, "profiles.json").exists():
|
|
262
|
+
finalize_collection(output)
|
|
263
|
+
summary = write_schema_report(output)
|
|
264
|
+
validations = [
|
|
265
|
+
validate_collection(
|
|
266
|
+
path.parent.parent if path.parent.name == ".pyxcom" else path.parent
|
|
267
|
+
)
|
|
268
|
+
for path in paths
|
|
269
|
+
]
|
|
270
|
+
if not all(result["valid"] for result in validations):
|
|
271
|
+
raise ValueError("One or more migrated collections failed validation")
|
|
272
|
+
return {
|
|
273
|
+
"changed_directories": changed,
|
|
274
|
+
"validated_directories": len(validations),
|
|
275
|
+
**summary,
|
|
276
|
+
}
|
pyxcom/search.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Discover public X post IDs through a configurable Nitter search mirror."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from urllib.parse import urlencode, urljoin, urlsplit
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from bs4 import BeautifulSoup
|
|
9
|
+
|
|
10
|
+
from .errors import APIError, RateLimitError
|
|
11
|
+
|
|
12
|
+
_POST_ID = re.compile(r"/status/(\d+)")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class SearchPage:
|
|
17
|
+
post_ids: list[str]
|
|
18
|
+
next_url: str | None
|
|
19
|
+
source_url: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MirrorSearch:
|
|
23
|
+
"""Searches only public mirror pages; X login cookies are never sent here."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
*,
|
|
28
|
+
base_url: str = "https://x.noodl3.net",
|
|
29
|
+
proxy: str | None = None,
|
|
30
|
+
timeout: float = 30,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.base_url = base_url.rstrip("/")
|
|
33
|
+
parts = urlsplit(self.base_url)
|
|
34
|
+
if parts.scheme != "https" or not parts.netloc:
|
|
35
|
+
raise ValueError("mirror base_url must be HTTPS")
|
|
36
|
+
self._host = parts.netloc
|
|
37
|
+
self._http = httpx.Client(proxy=proxy, timeout=timeout, follow_redirects=True)
|
|
38
|
+
|
|
39
|
+
def close(self) -> None:
|
|
40
|
+
self._http.close()
|
|
41
|
+
|
|
42
|
+
def initial_url(
|
|
43
|
+
self,
|
|
44
|
+
keyword: str,
|
|
45
|
+
*,
|
|
46
|
+
since: str | None = None,
|
|
47
|
+
until: str | None = None,
|
|
48
|
+
user: str | None = None,
|
|
49
|
+
) -> str:
|
|
50
|
+
terms = [keyword.strip()]
|
|
51
|
+
if not terms[0] and not user:
|
|
52
|
+
raise ValueError("keyword or user is required")
|
|
53
|
+
if user:
|
|
54
|
+
terms.append(f"from:{user}")
|
|
55
|
+
if since:
|
|
56
|
+
terms.append(f"since:{since}")
|
|
57
|
+
if until:
|
|
58
|
+
terms.append(f"until:{until}")
|
|
59
|
+
query = " ".join(part for part in terms if part)
|
|
60
|
+
return f"{self.base_url}/search?{urlencode({'f': 'tweets', 'q': query})}"
|
|
61
|
+
|
|
62
|
+
def page(self, url: str) -> SearchPage:
|
|
63
|
+
if urlsplit(url).scheme != "https" or urlsplit(url).netloc != self._host:
|
|
64
|
+
raise APIError("Search cursor changed mirror domain")
|
|
65
|
+
response = self._http.get(url)
|
|
66
|
+
if response.status_code == 429:
|
|
67
|
+
raise RateLimitError("Search mirror is rate-limited")
|
|
68
|
+
if response.status_code != 200:
|
|
69
|
+
raise APIError(f"Search mirror returned HTTP {response.status_code}")
|
|
70
|
+
soup = BeautifulSoup(response.text, "html.parser")
|
|
71
|
+
title = soup.title.get_text(" ", strip=True) if soup.title else ""
|
|
72
|
+
if "Error" in title or "bot" in title.lower() or "captcha" in title.lower():
|
|
73
|
+
raise APIError("Search mirror returned an error or verification page")
|
|
74
|
+
post_ids: list[str] = []
|
|
75
|
+
for item in soup.select(".timeline-item"):
|
|
76
|
+
date_link = item.select_one(".tweet-date a[href]")
|
|
77
|
+
if date_link is None:
|
|
78
|
+
continue
|
|
79
|
+
href = date_link.get("href")
|
|
80
|
+
if not isinstance(href, str):
|
|
81
|
+
continue
|
|
82
|
+
match = _POST_ID.search(href)
|
|
83
|
+
if match:
|
|
84
|
+
post_ids.append(match.group(1))
|
|
85
|
+
more = soup.select_one(".show-more a[href*='cursor=']")
|
|
86
|
+
more_href = more.get("href") if more else None
|
|
87
|
+
next_url = (
|
|
88
|
+
urljoin(str(response.url), more_href)
|
|
89
|
+
if isinstance(more_href, str)
|
|
90
|
+
else None
|
|
91
|
+
)
|
|
92
|
+
if next_url and urlsplit(next_url).netloc != self._host:
|
|
93
|
+
raise APIError("Search mirror returned a cross-domain cursor")
|
|
94
|
+
return SearchPage(list(dict.fromkeys(post_ids)), next_url, str(response.url))
|