@acedatacloud/skills 2026.714.3 → 2026.715.0
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.
- package/package.json +1 -1
- package/skills/telegram/SKILL.md +20 -6
- package/skills/telegram/scripts/tg.py +34 -3
- package/skills/xiaohongshu/SKILL.md +76 -136
- package/skills/xiaohongshu/tests/test_browser_contract.py +79 -0
- package/skills/xiaohongshu/README.md +0 -36
- package/skills/xiaohongshu/requirements.txt +0 -1
- package/skills/xiaohongshu/scripts/vendor/LICENSE.autoclaw-xiaohongshu-skills +0 -21
- package/skills/xiaohongshu/scripts/vendor/title_utils.py +0 -57
- package/skills/xiaohongshu/scripts/vendor/xhs/__init__.py +0 -1
- package/skills/xiaohongshu/scripts/vendor/xhs/cdp.py +0 -940
- package/skills/xiaohongshu/scripts/vendor/xhs/comment.py +0 -283
- package/skills/xiaohongshu/scripts/vendor/xhs/errors.py +0 -92
- package/skills/xiaohongshu/scripts/vendor/xhs/feed_detail.py +0 -550
- package/skills/xiaohongshu/scripts/vendor/xhs/feeds.py +0 -49
- package/skills/xiaohongshu/scripts/vendor/xhs/human.py +0 -79
- package/skills/xiaohongshu/scripts/vendor/xhs/like_favorite.py +0 -188
- package/skills/xiaohongshu/scripts/vendor/xhs/login.py +0 -377
- package/skills/xiaohongshu/scripts/vendor/xhs/publish.py +0 -1208
- package/skills/xiaohongshu/scripts/vendor/xhs/publish_long_article.py +0 -293
- package/skills/xiaohongshu/scripts/vendor/xhs/publish_video.py +0 -183
- package/skills/xiaohongshu/scripts/vendor/xhs/search.py +0 -226
- package/skills/xiaohongshu/scripts/vendor/xhs/selectors.py +0 -97
- package/skills/xiaohongshu/scripts/vendor/xhs/stealth.py +0 -316
- package/skills/xiaohongshu/scripts/vendor/xhs/types.py +0 -473
- package/skills/xiaohongshu/scripts/vendor/xhs/urls.py +0 -30
- package/skills/xiaohongshu/scripts/vendor/xhs/user_profile.py +0 -111
- package/skills/xiaohongshu/scripts/xiaohongshu.py +0 -1182
- package/skills/xiaohongshu/tests/test_xiaohongshu.py +0 -967
|
@@ -1,473 +0,0 @@
|
|
|
1
|
-
"""小红书数据类型定义,对应 Go types.go。"""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from dataclasses import dataclass, field
|
|
6
|
-
|
|
7
|
-
# ========== Feed 列表 ==========
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
@dataclass
|
|
11
|
-
class ImageInfo:
|
|
12
|
-
image_scene: str = ""
|
|
13
|
-
url: str = ""
|
|
14
|
-
|
|
15
|
-
@classmethod
|
|
16
|
-
def from_dict(cls, d: dict) -> ImageInfo:
|
|
17
|
-
return cls(
|
|
18
|
-
image_scene=d.get("imageScene", ""),
|
|
19
|
-
url=d.get("url", ""),
|
|
20
|
-
)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
@dataclass
|
|
24
|
-
class VideoCapability:
|
|
25
|
-
duration: int = 0 # 秒
|
|
26
|
-
|
|
27
|
-
@classmethod
|
|
28
|
-
def from_dict(cls, d: dict) -> VideoCapability:
|
|
29
|
-
return cls(duration=d.get("duration", 0))
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
@dataclass
|
|
33
|
-
class Video:
|
|
34
|
-
capa: VideoCapability = field(default_factory=VideoCapability)
|
|
35
|
-
|
|
36
|
-
@classmethod
|
|
37
|
-
def from_dict(cls, d: dict) -> Video:
|
|
38
|
-
return cls(capa=VideoCapability.from_dict(d.get("capa", {})))
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
@dataclass
|
|
42
|
-
class Cover:
|
|
43
|
-
width: int = 0
|
|
44
|
-
height: int = 0
|
|
45
|
-
url: str = ""
|
|
46
|
-
file_id: str = ""
|
|
47
|
-
url_pre: str = ""
|
|
48
|
-
url_default: str = ""
|
|
49
|
-
info_list: list[ImageInfo] = field(default_factory=list)
|
|
50
|
-
|
|
51
|
-
@classmethod
|
|
52
|
-
def from_dict(cls, d: dict) -> Cover:
|
|
53
|
-
return cls(
|
|
54
|
-
width=d.get("width", 0),
|
|
55
|
-
height=d.get("height", 0),
|
|
56
|
-
url=d.get("url", ""),
|
|
57
|
-
file_id=d.get("fileId", ""),
|
|
58
|
-
url_pre=d.get("urlPre", ""),
|
|
59
|
-
url_default=d.get("urlDefault", ""),
|
|
60
|
-
info_list=[ImageInfo.from_dict(i) for i in d.get("infoList", [])],
|
|
61
|
-
)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
@dataclass
|
|
65
|
-
class User:
|
|
66
|
-
user_id: str = ""
|
|
67
|
-
nickname: str = ""
|
|
68
|
-
nick_name: str = ""
|
|
69
|
-
avatar: str = ""
|
|
70
|
-
|
|
71
|
-
@classmethod
|
|
72
|
-
def from_dict(cls, d: dict) -> User:
|
|
73
|
-
return cls(
|
|
74
|
-
user_id=d.get("userId", ""),
|
|
75
|
-
nickname=d.get("nickname", ""),
|
|
76
|
-
nick_name=d.get("nickName", ""),
|
|
77
|
-
avatar=d.get("avatar", ""),
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
@dataclass
|
|
82
|
-
class InteractInfo:
|
|
83
|
-
liked: bool = False
|
|
84
|
-
liked_count: str = ""
|
|
85
|
-
shared_count: str = ""
|
|
86
|
-
comment_count: str = ""
|
|
87
|
-
collected_count: str = ""
|
|
88
|
-
collected: bool = False
|
|
89
|
-
|
|
90
|
-
@classmethod
|
|
91
|
-
def from_dict(cls, d: dict) -> InteractInfo:
|
|
92
|
-
return cls(
|
|
93
|
-
liked=d.get("liked", False),
|
|
94
|
-
liked_count=d.get("likedCount", ""),
|
|
95
|
-
shared_count=d.get("sharedCount", ""),
|
|
96
|
-
comment_count=d.get("commentCount", ""),
|
|
97
|
-
collected_count=d.get("collectedCount", ""),
|
|
98
|
-
collected=d.get("collected", False),
|
|
99
|
-
)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
@dataclass
|
|
103
|
-
class NoteCard:
|
|
104
|
-
type: str = ""
|
|
105
|
-
display_title: str = ""
|
|
106
|
-
user: User = field(default_factory=User)
|
|
107
|
-
interact_info: InteractInfo = field(default_factory=InteractInfo)
|
|
108
|
-
cover: Cover = field(default_factory=Cover)
|
|
109
|
-
video: Video | None = None
|
|
110
|
-
|
|
111
|
-
@classmethod
|
|
112
|
-
def from_dict(cls, d: dict) -> NoteCard:
|
|
113
|
-
video_data = d.get("video")
|
|
114
|
-
return cls(
|
|
115
|
-
type=d.get("type", ""),
|
|
116
|
-
display_title=d.get("displayTitle", ""),
|
|
117
|
-
user=User.from_dict(d.get("user", {})),
|
|
118
|
-
interact_info=InteractInfo.from_dict(d.get("interactInfo", {})),
|
|
119
|
-
cover=Cover.from_dict(d.get("cover", {})),
|
|
120
|
-
video=Video.from_dict(video_data) if video_data else None,
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
@dataclass
|
|
125
|
-
class Feed:
|
|
126
|
-
xsec_token: str = ""
|
|
127
|
-
id: str = ""
|
|
128
|
-
model_type: str = ""
|
|
129
|
-
note_card: NoteCard = field(default_factory=NoteCard)
|
|
130
|
-
index: int = 0
|
|
131
|
-
|
|
132
|
-
@classmethod
|
|
133
|
-
def from_dict(cls, d: dict) -> Feed:
|
|
134
|
-
return cls(
|
|
135
|
-
xsec_token=d.get("xsecToken", ""),
|
|
136
|
-
id=d.get("id", ""),
|
|
137
|
-
model_type=d.get("modelType", ""),
|
|
138
|
-
note_card=NoteCard.from_dict(d.get("noteCard", {})),
|
|
139
|
-
index=d.get("index", 0),
|
|
140
|
-
)
|
|
141
|
-
|
|
142
|
-
def to_dict(self) -> dict:
|
|
143
|
-
"""序列化为 JSON 兼容的字典。"""
|
|
144
|
-
result: dict = {
|
|
145
|
-
"id": self.id,
|
|
146
|
-
"xsecToken": self.xsec_token,
|
|
147
|
-
"modelType": self.model_type,
|
|
148
|
-
"index": self.index,
|
|
149
|
-
"displayTitle": self.note_card.display_title,
|
|
150
|
-
"type": self.note_card.type,
|
|
151
|
-
"user": {
|
|
152
|
-
"userId": self.note_card.user.user_id,
|
|
153
|
-
"nickname": self.note_card.user.nickname or self.note_card.user.nick_name,
|
|
154
|
-
},
|
|
155
|
-
"interactInfo": {
|
|
156
|
-
"likedCount": self.note_card.interact_info.liked_count,
|
|
157
|
-
"collectedCount": self.note_card.interact_info.collected_count,
|
|
158
|
-
"commentCount": self.note_card.interact_info.comment_count,
|
|
159
|
-
"sharedCount": self.note_card.interact_info.shared_count,
|
|
160
|
-
},
|
|
161
|
-
}
|
|
162
|
-
cover = self.note_card.cover
|
|
163
|
-
if cover.url or cover.url_default:
|
|
164
|
-
result["cover"] = cover.url or cover.url_default
|
|
165
|
-
if self.note_card.video:
|
|
166
|
-
result["video"] = {"duration": self.note_card.video.capa.duration}
|
|
167
|
-
return result
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
# ========== Feed 详情 ==========
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
@dataclass
|
|
174
|
-
class DetailImageInfo:
|
|
175
|
-
width: int = 0
|
|
176
|
-
height: int = 0
|
|
177
|
-
url_default: str = ""
|
|
178
|
-
url_pre: str = ""
|
|
179
|
-
live_photo: bool = False
|
|
180
|
-
|
|
181
|
-
@classmethod
|
|
182
|
-
def from_dict(cls, d: dict) -> DetailImageInfo:
|
|
183
|
-
return cls(
|
|
184
|
-
width=d.get("width", 0),
|
|
185
|
-
height=d.get("height", 0),
|
|
186
|
-
url_default=d.get("urlDefault", ""),
|
|
187
|
-
url_pre=d.get("urlPre", ""),
|
|
188
|
-
live_photo=d.get("livePhoto", False),
|
|
189
|
-
)
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
@dataclass
|
|
193
|
-
class Comment:
|
|
194
|
-
id: str = ""
|
|
195
|
-
note_id: str = ""
|
|
196
|
-
content: str = ""
|
|
197
|
-
like_count: str = ""
|
|
198
|
-
create_time: int = 0
|
|
199
|
-
ip_location: str = ""
|
|
200
|
-
liked: bool = False
|
|
201
|
-
user_info: User = field(default_factory=User)
|
|
202
|
-
sub_comment_count: str = ""
|
|
203
|
-
sub_comments: list[Comment] = field(default_factory=list)
|
|
204
|
-
show_tags: list[str] = field(default_factory=list)
|
|
205
|
-
|
|
206
|
-
@classmethod
|
|
207
|
-
def from_dict(cls, d: dict) -> Comment:
|
|
208
|
-
return cls(
|
|
209
|
-
id=d.get("id", ""),
|
|
210
|
-
note_id=d.get("noteId", ""),
|
|
211
|
-
content=d.get("content", ""),
|
|
212
|
-
like_count=d.get("likeCount", ""),
|
|
213
|
-
create_time=d.get("createTime", 0),
|
|
214
|
-
ip_location=d.get("ipLocation", ""),
|
|
215
|
-
liked=d.get("liked", False),
|
|
216
|
-
user_info=User.from_dict(d.get("userInfo", {})),
|
|
217
|
-
sub_comment_count=d.get("subCommentCount", ""),
|
|
218
|
-
sub_comments=[cls.from_dict(c) for c in d.get("subComments", []) or []],
|
|
219
|
-
show_tags=d.get("showTags", []) or [],
|
|
220
|
-
)
|
|
221
|
-
|
|
222
|
-
def to_dict(self) -> dict:
|
|
223
|
-
result: dict = {
|
|
224
|
-
"id": self.id,
|
|
225
|
-
"content": self.content,
|
|
226
|
-
"likeCount": self.like_count,
|
|
227
|
-
"createTime": self.create_time,
|
|
228
|
-
"ipLocation": self.ip_location,
|
|
229
|
-
"user": {
|
|
230
|
-
"userId": self.user_info.user_id,
|
|
231
|
-
"nickname": self.user_info.nickname or self.user_info.nick_name,
|
|
232
|
-
},
|
|
233
|
-
"subCommentCount": self.sub_comment_count,
|
|
234
|
-
}
|
|
235
|
-
if self.sub_comments:
|
|
236
|
-
result["subComments"] = [c.to_dict() for c in self.sub_comments]
|
|
237
|
-
return result
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
@dataclass
|
|
241
|
-
class CommentList:
|
|
242
|
-
list_: list[Comment] = field(default_factory=list)
|
|
243
|
-
cursor: str = ""
|
|
244
|
-
has_more: bool = False
|
|
245
|
-
|
|
246
|
-
@classmethod
|
|
247
|
-
def from_dict(cls, d: dict) -> CommentList:
|
|
248
|
-
return cls(
|
|
249
|
-
list_=[Comment.from_dict(c) for c in d.get("list", []) or []],
|
|
250
|
-
cursor=d.get("cursor", ""),
|
|
251
|
-
has_more=d.get("hasMore", False),
|
|
252
|
-
)
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
@dataclass
|
|
256
|
-
class FeedDetail:
|
|
257
|
-
note_id: str = ""
|
|
258
|
-
xsec_token: str = ""
|
|
259
|
-
title: str = ""
|
|
260
|
-
desc: str = ""
|
|
261
|
-
body: str = "" # DOM 正文(干净文本,不含 [话题] 标记)
|
|
262
|
-
tags: list[str] = field(default_factory=list) # 话题标签列表
|
|
263
|
-
type: str = ""
|
|
264
|
-
time: int = 0
|
|
265
|
-
ip_location: str = ""
|
|
266
|
-
user: User = field(default_factory=User)
|
|
267
|
-
interact_info: InteractInfo = field(default_factory=InteractInfo)
|
|
268
|
-
image_list: list[DetailImageInfo] = field(default_factory=list)
|
|
269
|
-
|
|
270
|
-
@classmethod
|
|
271
|
-
def from_dict(cls, d: dict) -> FeedDetail:
|
|
272
|
-
return cls(
|
|
273
|
-
note_id=d.get("noteId", ""),
|
|
274
|
-
xsec_token=d.get("xsecToken", ""),
|
|
275
|
-
title=d.get("title", ""),
|
|
276
|
-
desc=d.get("desc", ""),
|
|
277
|
-
body=d.get("_domBody", ""),
|
|
278
|
-
tags=d.get("_domTags", []),
|
|
279
|
-
type=d.get("type", ""),
|
|
280
|
-
time=d.get("time", 0),
|
|
281
|
-
ip_location=d.get("ipLocation", ""),
|
|
282
|
-
user=User.from_dict(d.get("user", {})),
|
|
283
|
-
interact_info=InteractInfo.from_dict(d.get("interactInfo", {})),
|
|
284
|
-
image_list=[DetailImageInfo.from_dict(i) for i in d.get("imageList", []) or []],
|
|
285
|
-
)
|
|
286
|
-
|
|
287
|
-
def to_dict(self) -> dict:
|
|
288
|
-
return {
|
|
289
|
-
"noteId": self.note_id,
|
|
290
|
-
"title": self.title,
|
|
291
|
-
"desc": self.desc,
|
|
292
|
-
"body": self.body,
|
|
293
|
-
"tags": self.tags,
|
|
294
|
-
"type": self.type,
|
|
295
|
-
"time": self.time,
|
|
296
|
-
"ipLocation": self.ip_location,
|
|
297
|
-
"user": {
|
|
298
|
-
"userId": self.user.user_id,
|
|
299
|
-
"nickname": self.user.nickname or self.user.nick_name,
|
|
300
|
-
},
|
|
301
|
-
"interactInfo": {
|
|
302
|
-
"liked": self.interact_info.liked,
|
|
303
|
-
"likedCount": self.interact_info.liked_count,
|
|
304
|
-
"collectedCount": self.interact_info.collected_count,
|
|
305
|
-
"collected": self.interact_info.collected,
|
|
306
|
-
"commentCount": self.interact_info.comment_count,
|
|
307
|
-
"sharedCount": self.interact_info.shared_count,
|
|
308
|
-
},
|
|
309
|
-
"imageList": [
|
|
310
|
-
{
|
|
311
|
-
"width": img.width,
|
|
312
|
-
"height": img.height,
|
|
313
|
-
"urlDefault": img.url_default,
|
|
314
|
-
}
|
|
315
|
-
for img in self.image_list
|
|
316
|
-
],
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
@dataclass
|
|
321
|
-
class FeedDetailResponse:
|
|
322
|
-
note: FeedDetail = field(default_factory=FeedDetail)
|
|
323
|
-
comments: CommentList = field(default_factory=CommentList)
|
|
324
|
-
|
|
325
|
-
@classmethod
|
|
326
|
-
def from_dict(cls, d: dict) -> FeedDetailResponse:
|
|
327
|
-
return cls(
|
|
328
|
-
note=FeedDetail.from_dict(d.get("note", {})),
|
|
329
|
-
comments=CommentList.from_dict(d.get("comments", {})),
|
|
330
|
-
)
|
|
331
|
-
|
|
332
|
-
def to_dict(self) -> dict:
|
|
333
|
-
return {
|
|
334
|
-
"note": self.note.to_dict(),
|
|
335
|
-
"comments": [c.to_dict() for c in self.comments.list_],
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
# ========== 用户主页 ==========
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
@dataclass
|
|
343
|
-
class UserBasicInfo:
|
|
344
|
-
gender: int = 0
|
|
345
|
-
ip_location: str = ""
|
|
346
|
-
desc: str = ""
|
|
347
|
-
imageb: str = ""
|
|
348
|
-
nickname: str = ""
|
|
349
|
-
images: str = ""
|
|
350
|
-
red_id: str = ""
|
|
351
|
-
|
|
352
|
-
@classmethod
|
|
353
|
-
def from_dict(cls, d: dict) -> UserBasicInfo:
|
|
354
|
-
return cls(
|
|
355
|
-
gender=d.get("gender", 0),
|
|
356
|
-
ip_location=d.get("ipLocation", ""),
|
|
357
|
-
desc=d.get("desc", ""),
|
|
358
|
-
imageb=d.get("imageb", ""),
|
|
359
|
-
nickname=d.get("nickname", ""),
|
|
360
|
-
images=d.get("images", ""),
|
|
361
|
-
red_id=d.get("redId", ""),
|
|
362
|
-
)
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
@dataclass
|
|
366
|
-
class UserInteraction:
|
|
367
|
-
type: str = ""
|
|
368
|
-
name: str = ""
|
|
369
|
-
count: str = ""
|
|
370
|
-
|
|
371
|
-
@classmethod
|
|
372
|
-
def from_dict(cls, d: dict) -> UserInteraction:
|
|
373
|
-
return cls(
|
|
374
|
-
type=d.get("type", ""),
|
|
375
|
-
name=d.get("name", ""),
|
|
376
|
-
count=d.get("count", ""),
|
|
377
|
-
)
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
@dataclass
|
|
381
|
-
class UserProfileResponse:
|
|
382
|
-
user_basic_info: UserBasicInfo = field(default_factory=UserBasicInfo)
|
|
383
|
-
interactions: list[UserInteraction] = field(default_factory=list)
|
|
384
|
-
feeds: list[Feed] = field(default_factory=list)
|
|
385
|
-
|
|
386
|
-
def to_dict(self) -> dict:
|
|
387
|
-
return {
|
|
388
|
-
"basicInfo": {
|
|
389
|
-
"nickname": self.user_basic_info.nickname,
|
|
390
|
-
"redId": self.user_basic_info.red_id,
|
|
391
|
-
"desc": self.user_basic_info.desc,
|
|
392
|
-
"gender": self.user_basic_info.gender,
|
|
393
|
-
"ipLocation": self.user_basic_info.ip_location,
|
|
394
|
-
},
|
|
395
|
-
"interactions": [
|
|
396
|
-
{"type": i.type, "name": i.name, "count": i.count} for i in self.interactions
|
|
397
|
-
],
|
|
398
|
-
"feeds": [f.to_dict() for f in self.feeds],
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
# ========== 搜索 ==========
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
@dataclass
|
|
406
|
-
class FilterOption:
|
|
407
|
-
"""搜索筛选选项。"""
|
|
408
|
-
|
|
409
|
-
sort_by: str = "" # 综合|最新|最多点赞|最多评论|最多收藏
|
|
410
|
-
note_type: str = "" # 不限|视频|图文
|
|
411
|
-
publish_time: str = "" # 不限|一天内|一周内|半年内
|
|
412
|
-
search_scope: str = "" # 不限|已看过|未看过|已关注
|
|
413
|
-
location: str = "" # 不限|同城|附近
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
# ========== 发布 ==========
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
@dataclass
|
|
420
|
-
class PublishImageContent:
|
|
421
|
-
"""图文发布内容。"""
|
|
422
|
-
|
|
423
|
-
title: str = ""
|
|
424
|
-
content: str = ""
|
|
425
|
-
tags: list[str] = field(default_factory=list)
|
|
426
|
-
image_paths: list[str] = field(default_factory=list)
|
|
427
|
-
schedule_time: str | None = None # ISO8601 格式,None 表示立即发布
|
|
428
|
-
is_original: bool = False
|
|
429
|
-
visibility: str = "" # 公开可见(默认)|仅自己可见|仅互关好友可见
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
@dataclass
|
|
433
|
-
class PublishVideoContent:
|
|
434
|
-
"""视频发布内容。"""
|
|
435
|
-
|
|
436
|
-
title: str = ""
|
|
437
|
-
content: str = ""
|
|
438
|
-
tags: list[str] = field(default_factory=list)
|
|
439
|
-
video_path: str = ""
|
|
440
|
-
schedule_time: str | None = None # ISO8601 格式
|
|
441
|
-
visibility: str = "" # 公开可见(默认)|仅自己可见|仅互关好友可见
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
# ========== 互动 ==========
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
@dataclass
|
|
448
|
-
class ActionResult:
|
|
449
|
-
"""通用动作响应(点赞/收藏等)。"""
|
|
450
|
-
|
|
451
|
-
feed_id: str = ""
|
|
452
|
-
success: bool = False
|
|
453
|
-
message: str = ""
|
|
454
|
-
|
|
455
|
-
def to_dict(self) -> dict:
|
|
456
|
-
return {
|
|
457
|
-
"feed_id": self.feed_id,
|
|
458
|
-
"success": self.success,
|
|
459
|
-
"message": self.message,
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
# ========== 评论加载配置 ==========
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
@dataclass
|
|
467
|
-
class CommentLoadConfig:
|
|
468
|
-
"""评论加载配置。"""
|
|
469
|
-
|
|
470
|
-
click_more_replies: bool = False
|
|
471
|
-
max_replies_threshold: int = 10
|
|
472
|
-
max_comment_items: int = 0 # 0 = 不限
|
|
473
|
-
scroll_speed: str = "normal" # slow|normal|fast
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
"""小红书 URL 常量和构建函数。"""
|
|
2
|
-
|
|
3
|
-
from urllib.parse import urlencode
|
|
4
|
-
|
|
5
|
-
# 基础页面
|
|
6
|
-
EXPLORE_URL = "https://www.xiaohongshu.com/explore"
|
|
7
|
-
HOME_URL = "https://www.xiaohongshu.com"
|
|
8
|
-
PUBLISH_URL = "https://creator.xiaohongshu.com/publish/publish?source=official"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def make_feed_detail_url(feed_id: str, xsec_token: str, xsec_source: str = "pc_feed") -> str:
|
|
12
|
-
"""构建 feed 详情页 URL。"""
|
|
13
|
-
return (
|
|
14
|
-
f"https://www.xiaohongshu.com/explore/{feed_id}"
|
|
15
|
-
f"?xsec_token={xsec_token}&xsec_source={xsec_source}"
|
|
16
|
-
)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def make_search_url(keyword: str) -> str:
|
|
20
|
-
"""构建搜索结果页 URL。"""
|
|
21
|
-
params = urlencode({"keyword": keyword, "source": "web_explore_feed"})
|
|
22
|
-
return f"https://www.xiaohongshu.com/search_result?{params}"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def make_user_profile_url(user_id: str, xsec_token: str) -> str:
|
|
26
|
-
"""构建用户主页 URL。"""
|
|
27
|
-
return (
|
|
28
|
-
f"https://www.xiaohongshu.com/user/profile/{user_id}"
|
|
29
|
-
f"?xsec_token={xsec_token}&xsec_source=pc_note"
|
|
30
|
-
)
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
"""用户主页,对应 Go xiaohongshu/user_profile.go。"""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
import logging
|
|
7
|
-
import time
|
|
8
|
-
|
|
9
|
-
from .cdp import Page
|
|
10
|
-
from .types import Feed, UserBasicInfo, UserInteraction, UserProfileResponse
|
|
11
|
-
from .urls import make_user_profile_url
|
|
12
|
-
|
|
13
|
-
logger = logging.getLogger(__name__)
|
|
14
|
-
|
|
15
|
-
# 提取用户数据的 JS
|
|
16
|
-
_EXTRACT_USER_DATA_JS = """
|
|
17
|
-
(() => {
|
|
18
|
-
if (window.__INITIAL_STATE__ &&
|
|
19
|
-
window.__INITIAL_STATE__.user &&
|
|
20
|
-
window.__INITIAL_STATE__.user.userPageData) {
|
|
21
|
-
const userPageData = window.__INITIAL_STATE__.user.userPageData;
|
|
22
|
-
const data = userPageData.value !== undefined ? userPageData.value : userPageData._value;
|
|
23
|
-
if (data) {
|
|
24
|
-
return JSON.stringify(data);
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
return "";
|
|
28
|
-
})()
|
|
29
|
-
"""
|
|
30
|
-
|
|
31
|
-
_EXTRACT_USER_NOTES_JS = """
|
|
32
|
-
(() => {
|
|
33
|
-
if (window.__INITIAL_STATE__ &&
|
|
34
|
-
window.__INITIAL_STATE__.user &&
|
|
35
|
-
window.__INITIAL_STATE__.user.notes) {
|
|
36
|
-
const notes = window.__INITIAL_STATE__.user.notes;
|
|
37
|
-
const data = notes.value !== undefined ? notes.value : notes._value;
|
|
38
|
-
if (data) {
|
|
39
|
-
return JSON.stringify(data);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
return "";
|
|
43
|
-
})()
|
|
44
|
-
"""
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def get_user_profile(page: Page, user_id: str, xsec_token: str) -> UserProfileResponse:
|
|
48
|
-
"""获取用户主页信息及帖子。
|
|
49
|
-
|
|
50
|
-
Args:
|
|
51
|
-
page: CDP 页面对象。
|
|
52
|
-
user_id: 用户 ID。
|
|
53
|
-
xsec_token: xsec_token。
|
|
54
|
-
|
|
55
|
-
Raises:
|
|
56
|
-
RuntimeError: 数据提取失败。
|
|
57
|
-
"""
|
|
58
|
-
url = make_user_profile_url(user_id, xsec_token)
|
|
59
|
-
page.navigate(url)
|
|
60
|
-
page.wait_for_load()
|
|
61
|
-
page.wait_dom_stable()
|
|
62
|
-
|
|
63
|
-
return _extract_user_profile_data(page)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def _extract_user_profile_data(page: Page) -> UserProfileResponse:
|
|
67
|
-
"""从页面提取用户资料数据。"""
|
|
68
|
-
# 等待 __INITIAL_STATE__
|
|
69
|
-
_wait_for_initial_state(page)
|
|
70
|
-
|
|
71
|
-
# 提取用户信息
|
|
72
|
-
user_data_result = page.evaluate(_EXTRACT_USER_DATA_JS)
|
|
73
|
-
if not user_data_result:
|
|
74
|
-
raise RuntimeError("user.userPageData.value not found in __INITIAL_STATE__")
|
|
75
|
-
|
|
76
|
-
# 提取用户帖子
|
|
77
|
-
notes_result = page.evaluate(_EXTRACT_USER_NOTES_JS)
|
|
78
|
-
if not notes_result:
|
|
79
|
-
raise RuntimeError("user.notes.value not found in __INITIAL_STATE__")
|
|
80
|
-
|
|
81
|
-
# 解析用户信息
|
|
82
|
-
user_page_data = json.loads(user_data_result)
|
|
83
|
-
basic_info = UserBasicInfo.from_dict(user_page_data.get("basicInfo", {}))
|
|
84
|
-
interactions = [UserInteraction.from_dict(i) for i in user_page_data.get("interactions", [])]
|
|
85
|
-
|
|
86
|
-
# 解析帖子(双重数组,展平)
|
|
87
|
-
notes_feeds_raw = json.loads(notes_result)
|
|
88
|
-
feeds: list[Feed] = []
|
|
89
|
-
for feed_group in notes_feeds_raw:
|
|
90
|
-
if isinstance(feed_group, list):
|
|
91
|
-
for f in feed_group:
|
|
92
|
-
feeds.append(Feed.from_dict(f))
|
|
93
|
-
elif isinstance(feed_group, dict):
|
|
94
|
-
feeds.append(Feed.from_dict(feed_group))
|
|
95
|
-
|
|
96
|
-
return UserProfileResponse(
|
|
97
|
-
user_basic_info=basic_info,
|
|
98
|
-
interactions=interactions,
|
|
99
|
-
feeds=feeds,
|
|
100
|
-
)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
def _wait_for_initial_state(page: Page, timeout: float = 10.0) -> None:
|
|
104
|
-
"""等待 __INITIAL_STATE__ 就绪。"""
|
|
105
|
-
deadline = time.monotonic() + timeout
|
|
106
|
-
while time.monotonic() < deadline:
|
|
107
|
-
ready = page.evaluate("window.__INITIAL_STATE__ !== undefined")
|
|
108
|
-
if ready:
|
|
109
|
-
return
|
|
110
|
-
time.sleep(0.5)
|
|
111
|
-
logger.warning("等待 __INITIAL_STATE__ 超时")
|