@acedatacloud/skills 2026.714.4 → 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.
Files changed (27) hide show
  1. package/package.json +1 -1
  2. package/skills/xiaohongshu/SKILL.md +76 -136
  3. package/skills/xiaohongshu/tests/test_browser_contract.py +79 -0
  4. package/skills/xiaohongshu/README.md +0 -36
  5. package/skills/xiaohongshu/requirements.txt +0 -1
  6. package/skills/xiaohongshu/scripts/vendor/LICENSE.autoclaw-xiaohongshu-skills +0 -21
  7. package/skills/xiaohongshu/scripts/vendor/title_utils.py +0 -57
  8. package/skills/xiaohongshu/scripts/vendor/xhs/__init__.py +0 -1
  9. package/skills/xiaohongshu/scripts/vendor/xhs/cdp.py +0 -940
  10. package/skills/xiaohongshu/scripts/vendor/xhs/comment.py +0 -283
  11. package/skills/xiaohongshu/scripts/vendor/xhs/errors.py +0 -92
  12. package/skills/xiaohongshu/scripts/vendor/xhs/feed_detail.py +0 -550
  13. package/skills/xiaohongshu/scripts/vendor/xhs/feeds.py +0 -49
  14. package/skills/xiaohongshu/scripts/vendor/xhs/human.py +0 -79
  15. package/skills/xiaohongshu/scripts/vendor/xhs/like_favorite.py +0 -188
  16. package/skills/xiaohongshu/scripts/vendor/xhs/login.py +0 -377
  17. package/skills/xiaohongshu/scripts/vendor/xhs/publish.py +0 -1208
  18. package/skills/xiaohongshu/scripts/vendor/xhs/publish_long_article.py +0 -293
  19. package/skills/xiaohongshu/scripts/vendor/xhs/publish_video.py +0 -183
  20. package/skills/xiaohongshu/scripts/vendor/xhs/search.py +0 -226
  21. package/skills/xiaohongshu/scripts/vendor/xhs/selectors.py +0 -97
  22. package/skills/xiaohongshu/scripts/vendor/xhs/stealth.py +0 -316
  23. package/skills/xiaohongshu/scripts/vendor/xhs/types.py +0 -473
  24. package/skills/xiaohongshu/scripts/vendor/xhs/urls.py +0 -30
  25. package/skills/xiaohongshu/scripts/vendor/xhs/user_profile.py +0 -111
  26. package/skills/xiaohongshu/scripts/xiaohongshu.py +0 -1182
  27. package/skills/xiaohongshu/tests/test_xiaohongshu.py +0 -967
@@ -1,188 +0,0 @@
1
- """点赞/收藏操作,对应 Go xiaohongshu/like_favorite.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 .errors import NoFeedDetailError
11
- from .selectors import COLLECT_BUTTON, LIKE_BUTTON
12
- from .types import ActionResult
13
- from .urls import make_feed_detail_url
14
-
15
- logger = logging.getLogger(__name__)
16
-
17
- # 从 __INITIAL_STATE__ 读取互动状态的 JS
18
- _GET_INTERACT_STATE_JS = """
19
- (() => {
20
- if (window.__INITIAL_STATE__ &&
21
- window.__INITIAL_STATE__.note &&
22
- window.__INITIAL_STATE__.note.noteDetailMap) {
23
- return JSON.stringify(window.__INITIAL_STATE__.note.noteDetailMap);
24
- }
25
- return "";
26
- })()
27
- """
28
-
29
-
30
- def _get_interact_state(page: Page, feed_id: str) -> tuple[bool, bool]:
31
- """读取笔记的点赞/收藏状态。
32
-
33
- Returns:
34
- (liked, collected)
35
-
36
- Raises:
37
- NoFeedDetailError: 无法获取状态。
38
- """
39
- result = page.evaluate(_GET_INTERACT_STATE_JS)
40
- if not result:
41
- raise NoFeedDetailError()
42
-
43
- note_detail_map = json.loads(result)
44
- detail = note_detail_map.get(feed_id)
45
- if not detail and len(note_detail_map) == 1:
46
- detail = next(iter(note_detail_map.values()))
47
-
48
- if not detail:
49
- raise NoFeedDetailError()
50
-
51
- interact = detail.get("note", {}).get("interactInfo", {})
52
- return interact.get("liked", False), interact.get("collected", False)
53
-
54
-
55
- def _prepare_page(page: Page, feed_id: str, xsec_token: str, xsec_source: str) -> None:
56
- """导航到 feed 详情页。"""
57
- url = make_feed_detail_url(feed_id, xsec_token, xsec_source)
58
- page.navigate(url)
59
- page.wait_for_load()
60
- page.wait_dom_stable()
61
- time.sleep(1)
62
-
63
-
64
- # ========== 点赞 ==========
65
-
66
-
67
- def like_feed(page: Page, feed_id: str, xsec_token: str, xsec_source: str = "pc_feed") -> ActionResult:
68
- """点赞笔记(幂等:已点赞则跳过)。"""
69
- _prepare_page(page, feed_id, xsec_token, xsec_source)
70
- return _toggle_like(page, feed_id, target_liked=True)
71
-
72
-
73
- def unlike_feed(page: Page, feed_id: str, xsec_token: str, xsec_source: str = "pc_feed") -> ActionResult:
74
- """取消点赞(幂等:未点赞则跳过)。"""
75
- _prepare_page(page, feed_id, xsec_token, xsec_source)
76
- return _toggle_like(page, feed_id, target_liked=False)
77
-
78
-
79
- def _toggle_like(page: Page, feed_id: str, target_liked: bool) -> ActionResult:
80
- """执行点赞/取消点赞操作。"""
81
- action_name = "点赞" if target_liked else "取消点赞"
82
-
83
- try:
84
- liked, _ = _get_interact_state(page, feed_id)
85
- except NoFeedDetailError:
86
- logger.error("无法读取点赞状态,拒绝切换")
87
- return ActionResult(feed_id=feed_id, success=False, message=f"{action_name}失败:无法读取当前状态")
88
-
89
- # 幂等检查
90
- if liked == target_liked:
91
- logger.info("feed %s 已%s,跳过", feed_id, action_name)
92
- return ActionResult(feed_id=feed_id, success=True, message=f"已{action_name}")
93
-
94
- # 点击
95
- page.click_element(LIKE_BUTTON)
96
- time.sleep(3)
97
-
98
- # 验证
99
- try:
100
- liked, _ = _get_interact_state(page, feed_id)
101
- if liked == target_liked:
102
- logger.info("feed %s %s成功", feed_id, action_name)
103
- return ActionResult(feed_id=feed_id, success=True, message=f"{action_name}成功")
104
- except NoFeedDetailError:
105
- pass
106
-
107
- logger.error("feed %s %s未确认成功", feed_id, action_name)
108
- return ActionResult(feed_id=feed_id, success=False, message=f"{action_name}失败:状态未变化")
109
-
110
-
111
- # ========== 收藏 ==========
112
-
113
-
114
- def favorite_feed(page: Page, feed_id: str, xsec_token: str, xsec_source: str = "pc_feed") -> ActionResult:
115
- """收藏笔记(幂等:已收藏则跳过)。"""
116
- _prepare_page(page, feed_id, xsec_token, xsec_source)
117
- return _toggle_favorite(page, feed_id, target_collected=True)
118
-
119
-
120
- def unfavorite_feed(page: Page, feed_id: str, xsec_token: str, xsec_source: str = "pc_feed") -> ActionResult:
121
- """取消收藏(幂等:未收藏则跳过)。"""
122
- _prepare_page(page, feed_id, xsec_token, xsec_source)
123
- return _toggle_favorite(page, feed_id, target_collected=False)
124
-
125
-
126
- def _wait_collect_button(page: Page, timeout: float = 5.0, interval: float = 0.2) -> bool:
127
- """等待收藏按钮出现。"""
128
- deadline = time.monotonic() + timeout
129
- while time.monotonic() < deadline:
130
- if page.has_element(COLLECT_BUTTON):
131
- return True
132
- time.sleep(interval)
133
- return False
134
-
135
-
136
- def _wait_collected_state(
137
- page: Page,
138
- feed_id: str,
139
- target_collected: bool,
140
- timeout: float = 3.0,
141
- interval: float = 0.3,
142
- ) -> bool:
143
- """短轮询验证收藏状态是否达到目标。"""
144
- deadline = time.monotonic() + timeout
145
- while time.monotonic() < deadline:
146
- try:
147
- _, collected = _get_interact_state(page, feed_id)
148
- if collected == target_collected:
149
- return True
150
- except NoFeedDetailError:
151
- pass
152
- time.sleep(interval)
153
- return False
154
-
155
-
156
- def _toggle_favorite(page: Page, feed_id: str, target_collected: bool) -> ActionResult:
157
- """执行收藏/取消收藏操作。"""
158
- action_name = "收藏" if target_collected else "取消收藏"
159
-
160
- try:
161
- _, collected = _get_interact_state(page, feed_id)
162
- except NoFeedDetailError:
163
- logger.error("无法读取收藏状态,拒绝切换")
164
- return ActionResult(feed_id=feed_id, success=False, message=f"{action_name}失败:无法读取当前状态")
165
-
166
- # 幂等检查
167
- if collected == target_collected:
168
- logger.info("feed %s 已%s,跳过", feed_id, action_name)
169
- return ActionResult(feed_id=feed_id, success=True, message=f"已{action_name}")
170
-
171
- if not _wait_collect_button(page, timeout=5.0):
172
- logger.error("feed %s 未找到收藏按钮: %s", feed_id, COLLECT_BUTTON)
173
- return ActionResult(
174
- feed_id=feed_id, success=False, message=f"{action_name}失败:未找到收藏按钮"
175
- )
176
-
177
- try:
178
- page.click_element(COLLECT_BUTTON)
179
- except Exception as error:
180
- logger.warning("feed %s 点击收藏按钮失败: %s", feed_id, error)
181
- return ActionResult(feed_id=feed_id, success=False, message=f"{action_name}失败:点击失败")
182
-
183
- if _wait_collected_state(page, feed_id, target_collected, timeout=3.0, interval=0.3):
184
- logger.info("feed %s %s成功", feed_id, action_name)
185
- return ActionResult(feed_id=feed_id, success=True, message=f"{action_name}成功")
186
-
187
- logger.error("feed %s %s未确认成功", feed_id, action_name)
188
- return ActionResult(feed_id=feed_id, success=False, message=f"{action_name}失败:状态未变化")
@@ -1,377 +0,0 @@
1
- """登录管理,对应 Go xiaohongshu/login.go。"""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import logging
7
- import os
8
- import tempfile
9
- import time
10
-
11
- _QR_DIR = os.path.join(tempfile.gettempdir(), "xhs")
12
- _QR_FILE = os.path.join(_QR_DIR, "login_qrcode.png")
13
-
14
- from .cdp import Page
15
- from .errors import RateLimitError
16
- from .human import sleep_random
17
- from .selectors import (
18
- AGREE_CHECKBOX,
19
- AGREE_CHECKBOX_CHECKED,
20
- CODE_INPUT,
21
- GET_CODE_BUTTON,
22
- LOGIN_CONTAINER,
23
- LOGIN_ERR_MSG,
24
- LOGIN_STATUS,
25
- LOGOUT_MENU_ITEM,
26
- LOGOUT_MORE_BUTTON,
27
- PHONE_INPUT,
28
- PHONE_LOGIN_SUBMIT,
29
- QRCODE_IMG,
30
- USER_NICKNAME,
31
- USER_PROFILE_NAV_LINK,
32
- )
33
- from .urls import EXPLORE_URL
34
-
35
- logger = logging.getLogger(__name__)
36
-
37
-
38
- def _wait_for_countdown(page: Page, timeout: float = 5.0) -> None:
39
- """等待"获取验证码"按钮出现倒计时数字,确认验证码已发送。
40
-
41
- 轮询按钮文字直到包含数字(如 "60s"),超时则抛出 RateLimitError。
42
- """
43
- deadline = time.monotonic() + timeout
44
- while time.monotonic() < deadline:
45
- btn_text = page.get_element_text(GET_CODE_BUTTON) or ""
46
- if any(ch.isdigit() for ch in btn_text):
47
- return
48
- time.sleep(0.3)
49
- raise RateLimitError()
50
-
51
-
52
-
53
- def get_current_user_nickname(page: Page) -> str:
54
- """获取当前登录用户的真实昵称,失败时返回空字符串(best-effort)。
55
-
56
- 流程:首页导航栏取个人主页 href → 导航过去 → 读 .user-name 文字。
57
- """
58
- try:
59
- page.navigate(EXPLORE_URL)
60
- page.wait_for_load()
61
- if not check_login_status(page):
62
- return ""
63
-
64
- # 从导航栏"我"的链接取个人主页 URL(含 /user/profile/<user_id>)
65
- profile_href = page.evaluate(
66
- f"document.querySelector({json.dumps(USER_PROFILE_NAV_LINK)})?.getAttribute('href') || ''"
67
- )
68
- if not profile_href:
69
- return ""
70
-
71
- # 导航到个人主页读取真实昵称
72
- profile_url = f"https://www.xiaohongshu.com{profile_href}"
73
- page.navigate(profile_url)
74
- page.wait_for_load()
75
- page.wait_dom_stable()
76
-
77
- nickname = page.evaluate(
78
- f"document.querySelector({json.dumps(USER_NICKNAME)})?.innerText?.trim() || ''"
79
- )
80
- return nickname or ""
81
- except Exception:
82
- logger.warning("获取用户昵称失败")
83
- return ""
84
-
85
-
86
- def check_login_status(page: Page) -> bool:
87
- """检查登录状态。
88
-
89
- Returns:
90
- True 已登录,False 未登录。
91
- """
92
- # 如果当前页面已在 explore,跳过重复导航
93
- current_url = page.evaluate("location.href") or ""
94
- if "explore" not in current_url:
95
- page.navigate(EXPLORE_URL)
96
- page.wait_for_load()
97
-
98
- # 直接等待登录状态或登录容器出现,替代 _wait_for_auth_ui
99
- deadline = time.monotonic() + 10.0
100
- while time.monotonic() < deadline:
101
- if page.has_element(LOGIN_STATUS):
102
- return True
103
- if page.has_element(LOGIN_CONTAINER):
104
- return False
105
- time.sleep(0.2)
106
- return False
107
-
108
-
109
- def fetch_qrcode(page: Page) -> tuple[bytes, str, bool]:
110
- """获取登录二维码图片。
111
-
112
- 直接读取 img.src(data:image/png;base64,...),跳过 Canvas 绘制。
113
-
114
- Returns:
115
- (png_bytes, b64_str, already_logged_in)
116
- - 如果已登录,返回 (b"", "", True)
117
- - 如果未登录,返回 (png_bytes, b64_str, False)
118
- """
119
- # 如果当前页面已在 explore(如 check-login 刚导航过),跳过重复导航
120
- current_url = page.evaluate("location.href") or ""
121
- if "explore" not in current_url:
122
- page.navigate(EXPLORE_URL)
123
- page.wait_for_load()
124
-
125
- # 快速检查是否已登录,避免无谓等待二维码
126
- if page.has_element(LOGIN_STATUS):
127
- return b"", "", True
128
-
129
- # 直接等待二维码元素出现,合并了 _wait_for_auth_ui 的逻辑
130
- page.wait_for_element(QRCODE_IMG, timeout=15.0)
131
-
132
- # img.src 本身就是 data:image/png;base64,...,直接读取
133
- src = page.evaluate(
134
- f"document.querySelector({json.dumps(QRCODE_IMG)})?.src || ''"
135
- )
136
- if not src or "base64," not in src:
137
- raise RuntimeError("二维码图片 src 读取失败")
138
-
139
- b64_str = src.split("base64,", 1)[1]
140
-
141
- import base64
142
- png_bytes = base64.b64decode(b64_str)
143
-
144
- return png_bytes, b64_str, False
145
-
146
-
147
- def _decode_qr_content(png_bytes: bytes) -> str | None:
148
- """通过 goqr.me read API 解码二维码内容。
149
-
150
- Returns:
151
- 解码后的文本(通常是登录 URL),失败返回 None。
152
- """
153
- import http.client
154
-
155
- boundary = "----XhsQrBoundary"
156
- body = (
157
- f"--{boundary}\r\n"
158
- f'Content-Disposition: form-data; name="file";'
159
- f' filename="qr.png"\r\n'
160
- f"Content-Type: image/png\r\n\r\n"
161
- ).encode() + png_bytes + f"\r\n--{boundary}--\r\n".encode()
162
-
163
- try:
164
- conn = http.client.HTTPSConnection(
165
- "api.qrserver.com", timeout=5
166
- )
167
- conn.request(
168
- "POST",
169
- "/v1/read-qr-code/",
170
- body=body,
171
- headers={
172
- "Content-Type": (
173
- f"multipart/form-data; boundary={boundary}"
174
- ),
175
- },
176
- )
177
- resp = conn.getresponse()
178
- if resp.status != 200:
179
- return None
180
- result = json.loads(resp.read().decode())
181
- data = result[0]["symbol"][0].get("data")
182
- return data if data else None
183
- except Exception:
184
- logger.debug("goqr.me 解码失败,将使用 base64 fallback")
185
- return None
186
-
187
-
188
- def make_qrcode_url(
189
- png_bytes: bytes,
190
- ) -> tuple[str, str | None]:
191
- """生成二维码展示 URL 和登录链接。
192
-
193
- 通过 goqr.me read API 解码 QR 内容,构造 API 图片 URL
194
- (~270 字符)和小红书官方登录链接。
195
-
196
- Returns:
197
- (image_url, login_url)
198
- - image_url: 可用于 markdown 图片的 URL
199
- - login_url: 小红书官方登录链接(解码失败时为 None)
200
- """
201
- import base64
202
- import urllib.parse
203
-
204
- qr_content = _decode_qr_content(png_bytes)
205
- if qr_content:
206
- image_url = (
207
- "https://api.qrserver.com/v1/create-qr-code/"
208
- "?size=300x300&data="
209
- + urllib.parse.quote(qr_content, safe="")
210
- )
211
- return image_url, qr_content
212
-
213
- # fallback: base64 data URL
214
- b64 = base64.b64encode(png_bytes).decode()
215
- return "data:image/png;base64," + b64, None
216
-
217
-
218
- def save_qrcode_to_file(png_bytes: bytes) -> str:
219
- """将二维码 PNG 字节保存到临时文件,返回文件路径。
220
-
221
- Args:
222
- png_bytes: CDP 截图返回的 PNG 字节。
223
-
224
- Returns:
225
- file_path: 保存的 PNG 文件绝对路径。
226
- """
227
- os.makedirs(_QR_DIR, exist_ok=True)
228
- with open(_QR_FILE, "wb") as f:
229
- f.write(png_bytes)
230
- logger.info("二维码已保存: %s", _QR_FILE)
231
- return _QR_FILE
232
-
233
-
234
- def send_phone_code(page: Page, phone: str) -> bool:
235
- """填写手机号并发送短信验证码。
236
-
237
- 适用于无界面服务器场景,全程通过 CDP 操作,无需扫码。
238
-
239
- Args:
240
- page: CDP 页面对象。
241
- phone: 手机号(不含国家码,如 13800138000)。
242
-
243
- Returns:
244
- True 验证码已发送,False 已登录(无需再登录)。
245
-
246
- Raises:
247
- RuntimeError: 找不到登录表单或手机号输入框。
248
- """
249
- # 如果当前页面已在 explore,跳过重复导航
250
- current_url = page.evaluate("location.href") or ""
251
- if "explore" not in current_url:
252
- page.navigate(EXPLORE_URL)
253
- page.wait_for_load()
254
-
255
- # 直接等待登录容器出现(合并了 _wait_for_auth_ui 的逻辑,避免重复等待)
256
- try:
257
- page.wait_for_element(LOGIN_CONTAINER, timeout=10.0)
258
- except Exception as exc:
259
- # 可能已登录(没有登录容器),检查登录状态
260
- if page.has_element(LOGIN_STATUS):
261
- return False
262
- raise RuntimeError("找不到登录表单") from exc
263
-
264
- if page.has_element(LOGIN_STATUS):
265
- return False
266
-
267
- sleep_random(200, 400)
268
-
269
- # 点击手机号输入框并逐字输入
270
- page.click_element(PHONE_INPUT)
271
- sleep_random(200, 400)
272
- page.type_text(phone, delay_ms=80)
273
- sleep_random(200, 400)
274
-
275
- # 先勾选用户协议,再点获取验证码
276
- if not page.has_element(AGREE_CHECKBOX_CHECKED):
277
- page.click_element(AGREE_CHECKBOX)
278
- sleep_random(300, 600)
279
-
280
- # 点击"获取验证码"
281
- page.click_element(GET_CODE_BUTTON)
282
-
283
- # 事件驱动:轮询按钮文字直到出现倒计时数字,替代固定 2-2.5s 等待
284
- _wait_for_countdown(page)
285
-
286
- logger.info("验证码已发送至 %s", phone[:3] + "****" + phone[-4:])
287
- return True
288
-
289
-
290
- def submit_phone_code(page: Page, code: str) -> bool:
291
- """填写短信验证码并提交登录。
292
-
293
- Args:
294
- page: CDP 页面对象。
295
- code: 收到的短信验证码。
296
-
297
- Returns:
298
- True 登录成功,False 失败(超时或验证码错误)。
299
- """
300
- # 点击验证码输入框,先清空再用 CDP 键盘事件逐字输入(isTrusted=true,React 能识别)
301
- page.click_element(CODE_INPUT)
302
- sleep_random(100, 200)
303
- page.evaluate(
304
- f"""(() => {{
305
- const el = document.querySelector({json.dumps(CODE_INPUT)});
306
- if (el && el.value) {{
307
- const setter = Object.getOwnPropertyDescriptor(
308
- window.HTMLInputElement.prototype, 'value'
309
- ).set;
310
- setter.call(el, '');
311
- el.dispatchEvent(new Event('input', {{ bubbles: true }}));
312
- }}
313
- }})()"""
314
- )
315
- page.type_text(code, delay_ms=0)
316
- sleep_random(100, 200)
317
-
318
- # 点击登录按钮
319
- page.click_element(PHONE_LOGIN_SUBMIT)
320
- sleep_random(500, 1000)
321
-
322
- # 检查是否有错误提示
323
- err = page.get_element_text(LOGIN_ERR_MSG)
324
- if err and err.strip():
325
- logger.warning("登录失败: %s", err.strip())
326
- return False
327
-
328
- return wait_for_login(page, timeout=30.0)
329
-
330
-
331
- def logout(page: Page) -> bool:
332
- """通过页面 UI 退出登录(点击"更多"→"退出登录")。
333
-
334
- Args:
335
- page: CDP 页面对象。
336
-
337
- Returns:
338
- True 退出成功,False 未登录或操作失败。
339
- """
340
- page.navigate(EXPLORE_URL)
341
- page.wait_for_load()
342
- sleep_random(800, 1500)
343
-
344
- if not page.has_element(LOGIN_STATUS):
345
- logger.info("当前未登录,无需退出")
346
- return False
347
-
348
- # 点击"更多"按钮展开菜单
349
- page.click_element(LOGOUT_MORE_BUTTON)
350
- sleep_random(500, 800)
351
-
352
- # 等待退出菜单项出现并点击
353
- page.wait_for_element(LOGOUT_MENU_ITEM, timeout=5.0)
354
- page.click_element(LOGOUT_MENU_ITEM)
355
- sleep_random(1000, 1500)
356
-
357
- logger.info("已退出登录")
358
- return True
359
-
360
-
361
- def wait_for_login(page: Page, timeout: float = 120.0) -> bool:
362
- """等待扫码登录完成。
363
-
364
- Args:
365
- page: CDP 页面对象。
366
- timeout: 超时时间(秒)。
367
-
368
- Returns:
369
- True 登录成功,False 超时。
370
- """
371
- deadline = time.monotonic() + timeout
372
- while time.monotonic() < deadline:
373
- if page.has_element(LOGIN_STATUS):
374
- logger.info("登录成功")
375
- return True
376
- time.sleep(0.3)
377
- return False