@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.
- package/package.json +1 -1
- 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,293 +0,0 @@
|
|
|
1
|
-
"""长文发布模式,参考 cdp_publish.py 的长文工作流。"""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
import logging
|
|
7
|
-
import time
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
|
|
10
|
-
from .cdp import Page
|
|
11
|
-
from .errors import PublishError
|
|
12
|
-
from .publish import _find_content_element
|
|
13
|
-
from .selectors import (
|
|
14
|
-
AUTO_FORMAT_BUTTON_TEXT,
|
|
15
|
-
CONTENT_EDITOR,
|
|
16
|
-
LONG_ARTICLE_TITLE,
|
|
17
|
-
NEW_CREATION_BUTTON_TEXT,
|
|
18
|
-
NEXT_STEP_BUTTON_TEXT,
|
|
19
|
-
TEMPLATE_CARD,
|
|
20
|
-
TEMPLATE_TITLE,
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
logger = logging.getLogger(__name__)
|
|
24
|
-
|
|
25
|
-
# 等待常量
|
|
26
|
-
_AUTO_FORMAT_WAIT = 3.0
|
|
27
|
-
_TEMPLATE_WAIT_ROUNDS = 15
|
|
28
|
-
_PAGE_LOAD_WAIT = 3.0
|
|
29
|
-
_LONG_ARTICLE_URL = (
|
|
30
|
-
"https://creator.xiaohongshu.com/publish/publish?source=official&target=article"
|
|
31
|
-
)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def publish_long_article(
|
|
35
|
-
page: Page,
|
|
36
|
-
title: str,
|
|
37
|
-
content: str,
|
|
38
|
-
image_paths: list[str] | None = None,
|
|
39
|
-
) -> list[str]:
|
|
40
|
-
"""长文发布:导航 → 点击写长文 → 新的创作 → 填写标题正文 → 一键排版。
|
|
41
|
-
|
|
42
|
-
返回可用模板名称列表。
|
|
43
|
-
|
|
44
|
-
Args:
|
|
45
|
-
page: CDP 页面对象。
|
|
46
|
-
title: 长文标题。
|
|
47
|
-
content: 长文正文(段落用换行分隔)。
|
|
48
|
-
image_paths: 可选的图片路径列表(插入编辑器)。
|
|
49
|
-
|
|
50
|
-
Returns:
|
|
51
|
-
可用模板名称列表。
|
|
52
|
-
|
|
53
|
-
Raises:
|
|
54
|
-
PublishError: 操作失败。
|
|
55
|
-
"""
|
|
56
|
-
# 1. 直达长文入口,避免 tab_switch 的 SPA 重载竞态。
|
|
57
|
-
page.navigate(_LONG_ARTICLE_URL)
|
|
58
|
-
page.wait_for_load(timeout=120)
|
|
59
|
-
page.wait_dom_stable()
|
|
60
|
-
time.sleep(1)
|
|
61
|
-
|
|
62
|
-
# 2. 点击"新的创作"
|
|
63
|
-
_click_new_creation(page)
|
|
64
|
-
|
|
65
|
-
# 3. 填写标题(textarea)
|
|
66
|
-
_fill_long_title(page, title)
|
|
67
|
-
|
|
68
|
-
# 4. 填写正文(TipTap 编辑器)
|
|
69
|
-
_fill_long_content(page, content)
|
|
70
|
-
|
|
71
|
-
# 5. 可选:插入图片到编辑器
|
|
72
|
-
if image_paths:
|
|
73
|
-
_insert_images_to_editor(page, image_paths)
|
|
74
|
-
|
|
75
|
-
# 6. 点击"一键排版"
|
|
76
|
-
_click_auto_format(page)
|
|
77
|
-
|
|
78
|
-
# 7. 等待模板加载并返回名称列表
|
|
79
|
-
_wait_for_templates(page)
|
|
80
|
-
template_names = get_template_names(page)
|
|
81
|
-
logger.info("模板加载完成: %s", template_names)
|
|
82
|
-
return template_names
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def get_template_names(page: Page) -> list[str]:
|
|
86
|
-
"""获取当前可用的排版模板名称列表。
|
|
87
|
-
|
|
88
|
-
Args:
|
|
89
|
-
page: CDP 页面对象。
|
|
90
|
-
|
|
91
|
-
Returns:
|
|
92
|
-
模板名称列表。
|
|
93
|
-
"""
|
|
94
|
-
names = page.evaluate(
|
|
95
|
-
f"""
|
|
96
|
-
(() => {{
|
|
97
|
-
const cards = document.querySelectorAll({json.dumps(TEMPLATE_CARD)});
|
|
98
|
-
const names = [];
|
|
99
|
-
for (const card of cards) {{
|
|
100
|
-
const title = card.querySelector({json.dumps(TEMPLATE_TITLE)});
|
|
101
|
-
names.push(title ? title.textContent.trim() : 'Template ' + names.length);
|
|
102
|
-
}}
|
|
103
|
-
return names;
|
|
104
|
-
}})()
|
|
105
|
-
"""
|
|
106
|
-
)
|
|
107
|
-
return names or []
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def select_template(page: Page, template_name: str) -> bool:
|
|
111
|
-
"""选择指定名称的排版模板。
|
|
112
|
-
|
|
113
|
-
Args:
|
|
114
|
-
page: CDP 页面对象。
|
|
115
|
-
template_name: 模板名称。
|
|
116
|
-
|
|
117
|
-
Returns:
|
|
118
|
-
是否成功选择。
|
|
119
|
-
"""
|
|
120
|
-
clicked = page.evaluate(
|
|
121
|
-
f"""
|
|
122
|
-
(() => {{
|
|
123
|
-
const cards = document.querySelectorAll({json.dumps(TEMPLATE_CARD)});
|
|
124
|
-
for (const card of cards) {{
|
|
125
|
-
const title = card.querySelector({json.dumps(TEMPLATE_TITLE)});
|
|
126
|
-
if (title && title.textContent.trim() === {json.dumps(template_name)}) {{
|
|
127
|
-
card.click();
|
|
128
|
-
return true;
|
|
129
|
-
}}
|
|
130
|
-
}}
|
|
131
|
-
return false;
|
|
132
|
-
}})()
|
|
133
|
-
"""
|
|
134
|
-
)
|
|
135
|
-
|
|
136
|
-
if clicked:
|
|
137
|
-
logger.info("已选择模板: %s", template_name)
|
|
138
|
-
time.sleep(1)
|
|
139
|
-
else:
|
|
140
|
-
logger.warning("未找到模板: %s", template_name)
|
|
141
|
-
|
|
142
|
-
return bool(clicked)
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def click_next_and_fill_description(page: Page, description: str) -> None:
|
|
146
|
-
"""点击下一步,进入发布页并填写正文描述。
|
|
147
|
-
|
|
148
|
-
注意:发布页有独立的正文编辑器,需单独填入。
|
|
149
|
-
如果 description 超过 1000 字,应压缩到 800 字左右。
|
|
150
|
-
|
|
151
|
-
Args:
|
|
152
|
-
page: CDP 页面对象。
|
|
153
|
-
description: 发布页正文描述。
|
|
154
|
-
|
|
155
|
-
Raises:
|
|
156
|
-
PublishError: 操作失败。
|
|
157
|
-
"""
|
|
158
|
-
# 点击"下一步"
|
|
159
|
-
_click_button_by_text(page, NEXT_STEP_BUTTON_TEXT)
|
|
160
|
-
time.sleep(_PAGE_LOAD_WAIT)
|
|
161
|
-
|
|
162
|
-
# 填写发布页描述
|
|
163
|
-
if description:
|
|
164
|
-
# 截断描述到 1000 字以内
|
|
165
|
-
if len(description) > 1000:
|
|
166
|
-
description = description[:800]
|
|
167
|
-
logger.warning("描述超过1000字,已截断到800字")
|
|
168
|
-
|
|
169
|
-
content_selector = _find_content_element(page)
|
|
170
|
-
page.input_content_editable(content_selector, description)
|
|
171
|
-
logger.info("已填写发布页描述")
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
# ========== 内部辅助函数 ==========
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
def _click_new_creation(page: Page) -> None:
|
|
178
|
-
"""点击"新的创作"按钮。"""
|
|
179
|
-
deadline = time.monotonic() + 15
|
|
180
|
-
clicked = False
|
|
181
|
-
while time.monotonic() < deadline:
|
|
182
|
-
if page.has_element("button.new-btn"):
|
|
183
|
-
page.click_element("button.new-btn")
|
|
184
|
-
clicked = True
|
|
185
|
-
break
|
|
186
|
-
time.sleep(0.25)
|
|
187
|
-
if not clicked:
|
|
188
|
-
_click_button_by_text(page, NEW_CREATION_BUTTON_TEXT)
|
|
189
|
-
time.sleep(2)
|
|
190
|
-
page.wait_for_load(timeout=60)
|
|
191
|
-
page.wait_dom_stable()
|
|
192
|
-
logger.info("已点击'新的创作'")
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
def _fill_long_title(page: Page, title: str) -> None:
|
|
196
|
-
"""填写长文标题(textarea,需使用 native setter)。"""
|
|
197
|
-
page.wait_for_element(LONG_ARTICLE_TITLE, timeout=10)
|
|
198
|
-
|
|
199
|
-
page.evaluate(
|
|
200
|
-
f"""
|
|
201
|
-
(() => {{
|
|
202
|
-
const el = document.querySelector({json.dumps(LONG_ARTICLE_TITLE)});
|
|
203
|
-
if (!el) return false;
|
|
204
|
-
const nativeSetter = Object.getOwnPropertyDescriptor(
|
|
205
|
-
window.HTMLTextAreaElement.prototype, 'value'
|
|
206
|
-
).set;
|
|
207
|
-
el.focus();
|
|
208
|
-
nativeSetter.call(el, {json.dumps(title)});
|
|
209
|
-
el.dispatchEvent(new Event('input', {{ bubbles: true }}));
|
|
210
|
-
el.dispatchEvent(new Event('change', {{ bubbles: true }}));
|
|
211
|
-
return true;
|
|
212
|
-
}})()
|
|
213
|
-
"""
|
|
214
|
-
)
|
|
215
|
-
logger.info("已填写长文标题: %s", title[:20])
|
|
216
|
-
time.sleep(0.5)
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
def _fill_long_content(page: Page, content: str) -> None:
|
|
220
|
-
"""填写长文正文(TipTap/ProseMirror 编辑器)。"""
|
|
221
|
-
content_selector = CONTENT_EDITOR
|
|
222
|
-
if not page.has_element(CONTENT_EDITOR):
|
|
223
|
-
content_selector = _find_content_element(page)
|
|
224
|
-
|
|
225
|
-
page.input_content_editable(content_selector, content)
|
|
226
|
-
logger.info("已填写长文正文 (%d 字)", len(content))
|
|
227
|
-
time.sleep(1)
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
def _insert_images_to_editor(page: Page, image_paths: list[str]) -> None:
|
|
231
|
-
"""将图片插入到编辑器中。"""
|
|
232
|
-
for img_path in image_paths:
|
|
233
|
-
file_uri = Path(img_path).resolve().as_uri()
|
|
234
|
-
page.evaluate(
|
|
235
|
-
f"""
|
|
236
|
-
(() => {{
|
|
237
|
-
const editor = document.querySelector({json.dumps(CONTENT_EDITOR)});
|
|
238
|
-
if (!editor) return false;
|
|
239
|
-
const img = document.createElement('img');
|
|
240
|
-
img.src = {json.dumps(file_uri)};
|
|
241
|
-
editor.appendChild(img);
|
|
242
|
-
editor.dispatchEvent(new Event('input', {{ bubbles: true }}));
|
|
243
|
-
return true;
|
|
244
|
-
}})()
|
|
245
|
-
"""
|
|
246
|
-
)
|
|
247
|
-
logger.info("已插入 %d 张图片到编辑器", len(image_paths))
|
|
248
|
-
time.sleep(1)
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
def _click_auto_format(page: Page) -> None:
|
|
252
|
-
"""点击"一键排版"按钮。"""
|
|
253
|
-
_click_button_by_text(page, AUTO_FORMAT_BUTTON_TEXT)
|
|
254
|
-
logger.info("已点击'一键排版',等待模板加载...")
|
|
255
|
-
time.sleep(_AUTO_FORMAT_WAIT)
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
def _wait_for_templates(page: Page) -> bool:
|
|
259
|
-
"""等待模板卡片出现。"""
|
|
260
|
-
for _ in range(_TEMPLATE_WAIT_ROUNDS):
|
|
261
|
-
count = page.get_elements_count(TEMPLATE_CARD)
|
|
262
|
-
if count and count > 0:
|
|
263
|
-
logger.info("发现 %d 个模板卡片", count)
|
|
264
|
-
return True
|
|
265
|
-
time.sleep(1)
|
|
266
|
-
|
|
267
|
-
logger.warning("等待模板卡片超时")
|
|
268
|
-
return False
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
def _click_button_by_text(page: Page, text: str) -> None:
|
|
272
|
-
"""通过文本内容查找并点击按钮(通用方法)。"""
|
|
273
|
-
clicked = page.evaluate(
|
|
274
|
-
f"""
|
|
275
|
-
(() => {{
|
|
276
|
-
const elems = document.querySelectorAll(
|
|
277
|
-
'button, [role="button"], span, div, a, [class*="btn"]'
|
|
278
|
-
);
|
|
279
|
-
for (const el of elems) {{
|
|
280
|
-
if (el.textContent.trim() === {json.dumps(text)}) {{
|
|
281
|
-
const rect = el.getBoundingClientRect();
|
|
282
|
-
if (rect.width === 0 || rect.height === 0) continue;
|
|
283
|
-
el.click();
|
|
284
|
-
return true;
|
|
285
|
-
}}
|
|
286
|
-
}}
|
|
287
|
-
return false;
|
|
288
|
-
}})()
|
|
289
|
-
"""
|
|
290
|
-
)
|
|
291
|
-
|
|
292
|
-
if not clicked:
|
|
293
|
-
raise PublishError(f"未找到'{text}'按钮,页面结构可能已变化")
|
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
"""视频发布,对应 Go xiaohongshu/publish_video.go。"""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import logging
|
|
6
|
-
import os
|
|
7
|
-
import time
|
|
8
|
-
|
|
9
|
-
from .cdp import Page
|
|
10
|
-
from .errors import PublishError, UploadTimeoutError
|
|
11
|
-
from .publish import (
|
|
12
|
-
_click_publish_tab,
|
|
13
|
-
_find_content_element,
|
|
14
|
-
_input_tags,
|
|
15
|
-
_navigate_to_publish_page,
|
|
16
|
-
_set_schedule_publish,
|
|
17
|
-
_set_visibility,
|
|
18
|
-
click_publish_button,
|
|
19
|
-
)
|
|
20
|
-
from .selectors import (
|
|
21
|
-
FILE_INPUT,
|
|
22
|
-
PUBLISH_BUTTON,
|
|
23
|
-
TITLE_INPUT,
|
|
24
|
-
UPLOAD_INPUT,
|
|
25
|
-
)
|
|
26
|
-
from .types import PublishVideoContent
|
|
27
|
-
|
|
28
|
-
logger = logging.getLogger(__name__)
|
|
29
|
-
VIDEO_OPERATION_TIMEOUT_SECONDS = 100.0
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def publish_video_content(page: Page, content: PublishVideoContent) -> None:
|
|
33
|
-
"""发布视频内容(填写表单 + 点击发布)。
|
|
34
|
-
|
|
35
|
-
Args:
|
|
36
|
-
page: CDP 页面对象。
|
|
37
|
-
content: 视频发布内容。
|
|
38
|
-
|
|
39
|
-
Raises:
|
|
40
|
-
PublishError: 发布失败。
|
|
41
|
-
UploadTimeoutError: 上传/处理超时。
|
|
42
|
-
"""
|
|
43
|
-
deadline = time.monotonic() + VIDEO_OPERATION_TIMEOUT_SECONDS
|
|
44
|
-
fill_publish_video_form(page, content, deadline=deadline)
|
|
45
|
-
click_publish_video_button(page, deadline=deadline)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def fill_publish_video_form(
|
|
49
|
-
page: Page, content: PublishVideoContent, *, deadline: float | None = None
|
|
50
|
-
) -> None:
|
|
51
|
-
"""填写视频发布表单,不点击发布按钮。
|
|
52
|
-
|
|
53
|
-
Args:
|
|
54
|
-
page: CDP 页面对象。
|
|
55
|
-
content: 视频发布内容。
|
|
56
|
-
|
|
57
|
-
Raises:
|
|
58
|
-
PublishError: 填写失败。
|
|
59
|
-
UploadTimeoutError: 上传/处理超时。
|
|
60
|
-
"""
|
|
61
|
-
if not content.video_path:
|
|
62
|
-
raise PublishError("视频不能为空")
|
|
63
|
-
|
|
64
|
-
# 导航到发布页
|
|
65
|
-
_navigate_to_publish_page(page)
|
|
66
|
-
|
|
67
|
-
# 点击"上传视频" TAB
|
|
68
|
-
_click_publish_tab(page, "上传视频")
|
|
69
|
-
time.sleep(1)
|
|
70
|
-
|
|
71
|
-
# 上传视频
|
|
72
|
-
operation_deadline = deadline or time.monotonic() + VIDEO_OPERATION_TIMEOUT_SECONDS
|
|
73
|
-
_upload_video(page, content.video_path, deadline=operation_deadline)
|
|
74
|
-
|
|
75
|
-
# 填写表单(不点击发布)
|
|
76
|
-
_fill_publish_video_form(
|
|
77
|
-
page,
|
|
78
|
-
content.title,
|
|
79
|
-
content.content,
|
|
80
|
-
content.tags,
|
|
81
|
-
content.schedule_time,
|
|
82
|
-
content.visibility,
|
|
83
|
-
)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def click_publish_video_button(page: Page, *, deadline: float | None = None) -> None:
|
|
87
|
-
"""点击视频发布按钮。
|
|
88
|
-
|
|
89
|
-
Args:
|
|
90
|
-
page: CDP 页面对象。
|
|
91
|
-
"""
|
|
92
|
-
operation_deadline = deadline or time.monotonic() + VIDEO_OPERATION_TIMEOUT_SECONDS
|
|
93
|
-
_wait_for_publish_button_clickable(page, deadline=operation_deadline)
|
|
94
|
-
click_publish_button(page)
|
|
95
|
-
logger.info("视频发布完成")
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def _upload_video(page: Page, video_path: str, *, deadline: float) -> None:
|
|
99
|
-
"""上传视频文件。"""
|
|
100
|
-
if not os.path.exists(video_path):
|
|
101
|
-
raise PublishError(f"视频文件不存在: {video_path}")
|
|
102
|
-
|
|
103
|
-
# 查找上传输入框
|
|
104
|
-
selector = UPLOAD_INPUT if page.has_element(UPLOAD_INPUT) else FILE_INPUT
|
|
105
|
-
page.set_file_input(selector, [video_path])
|
|
106
|
-
|
|
107
|
-
# 等待发布按钮可点击(视频处理完成)
|
|
108
|
-
_wait_for_publish_button_clickable(page, deadline=deadline)
|
|
109
|
-
logger.info("视频上传/处理完成")
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
def _wait_for_publish_button_clickable(page: Page, *, deadline: float) -> None:
|
|
113
|
-
"""等待发布按钮可点击(视频处理可能需要较长时间)。"""
|
|
114
|
-
logger.info("开始等待发布按钮可点击(视频)")
|
|
115
|
-
|
|
116
|
-
while time.monotonic() < deadline:
|
|
117
|
-
clickable = page.evaluate(
|
|
118
|
-
f"""
|
|
119
|
-
(() => {{
|
|
120
|
-
const host = document.querySelector(
|
|
121
|
-
'xhs-publish-btn[is-publish="true"]'
|
|
122
|
-
);
|
|
123
|
-
if (host) {{
|
|
124
|
-
return host.getAttribute('submit-disabled') !== 'true'
|
|
125
|
-
&& host.getAttribute('submit-loading') !== 'true';
|
|
126
|
-
}}
|
|
127
|
-
const btn = document.querySelector({_js_str(PUBLISH_BUTTON)});
|
|
128
|
-
if (!btn) return false;
|
|
129
|
-
const rect = btn.getBoundingClientRect();
|
|
130
|
-
if (rect.width === 0 || rect.height === 0) return false;
|
|
131
|
-
if (btn.disabled) return false;
|
|
132
|
-
if (btn.classList.contains('disabled')) return false;
|
|
133
|
-
return true;
|
|
134
|
-
}})()
|
|
135
|
-
"""
|
|
136
|
-
)
|
|
137
|
-
if clickable:
|
|
138
|
-
return
|
|
139
|
-
time.sleep(1)
|
|
140
|
-
|
|
141
|
-
raise UploadTimeoutError("等待发布按钮可点击超时(100秒)")
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
def _fill_publish_video_form(
|
|
145
|
-
page: Page,
|
|
146
|
-
title: str,
|
|
147
|
-
content: str,
|
|
148
|
-
tags: list[str],
|
|
149
|
-
schedule_time: str | None,
|
|
150
|
-
visibility: str,
|
|
151
|
-
) -> None:
|
|
152
|
-
"""填写视频表单(不点击发布)。"""
|
|
153
|
-
# 标题
|
|
154
|
-
page.input_text(TITLE_INPUT, title)
|
|
155
|
-
time.sleep(1)
|
|
156
|
-
|
|
157
|
-
# 正文 + 标签
|
|
158
|
-
content_selector = _find_content_element(page)
|
|
159
|
-
page.input_content_editable(content_selector, content)
|
|
160
|
-
|
|
161
|
-
# 回点标题
|
|
162
|
-
time.sleep(1)
|
|
163
|
-
page.click_element(TITLE_INPUT)
|
|
164
|
-
|
|
165
|
-
if tags:
|
|
166
|
-
_input_tags(page, content_selector, tags)
|
|
167
|
-
time.sleep(1)
|
|
168
|
-
|
|
169
|
-
# 定时发布
|
|
170
|
-
if schedule_time:
|
|
171
|
-
_set_schedule_publish(page, schedule_time)
|
|
172
|
-
|
|
173
|
-
# 可见范围
|
|
174
|
-
_set_visibility(page, visibility)
|
|
175
|
-
|
|
176
|
-
logger.info("视频表单填写完成,等待确认发布")
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
def _js_str(s: str) -> str:
|
|
180
|
-
"""将 Python 字符串转为 JS 字面量。"""
|
|
181
|
-
import json
|
|
182
|
-
|
|
183
|
-
return json.dumps(s)
|
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
"""搜索 Feeds,对应 Go xiaohongshu/search.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 NoFeedsError
|
|
11
|
-
from .human import sleep_random
|
|
12
|
-
from .selectors import FILTER_BUTTON, FILTER_PANEL
|
|
13
|
-
from .types import Feed, FilterOption
|
|
14
|
-
from .urls import make_search_url
|
|
15
|
-
|
|
16
|
-
logger = logging.getLogger(__name__)
|
|
17
|
-
|
|
18
|
-
# 筛选选项映射表:{筛选组索引: [文本, ...]}
|
|
19
|
-
_FILTER_OPTIONS: dict[int, list[str]] = {
|
|
20
|
-
1: ["综合", "最新", "最多点赞", "最多评论", "最多收藏"],
|
|
21
|
-
2: ["不限", "视频", "图文"],
|
|
22
|
-
3: ["不限", "一天内", "一周内", "半年内"],
|
|
23
|
-
4: ["不限", "已看过", "未看过", "已关注"],
|
|
24
|
-
5: ["不限", "同城", "附近"],
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
# 从 __INITIAL_STATE__ 提取搜索结果的 JS
|
|
28
|
-
_EXTRACT_SEARCH_JS = """
|
|
29
|
-
(() => {
|
|
30
|
-
const s = window.__INITIAL_STATE__?.search;
|
|
31
|
-
if (!s?.feeds) return "";
|
|
32
|
-
const data = s.feeds.value !== undefined ? s.feeds.value : s.feeds._value;
|
|
33
|
-
return data ? JSON.stringify(data) : "";
|
|
34
|
-
})()
|
|
35
|
-
"""
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def _find_internal_option(group_index: int, text: str) -> tuple[int, str]:
|
|
39
|
-
"""查找内部筛选选项。
|
|
40
|
-
|
|
41
|
-
Returns:
|
|
42
|
-
(filters_index, text)
|
|
43
|
-
|
|
44
|
-
Raises:
|
|
45
|
-
ValueError: 未找到匹配的选项。
|
|
46
|
-
"""
|
|
47
|
-
options = _FILTER_OPTIONS.get(group_index)
|
|
48
|
-
if not options:
|
|
49
|
-
raise ValueError(f"筛选组 {group_index} 不存在")
|
|
50
|
-
|
|
51
|
-
if text in options:
|
|
52
|
-
return group_index, text
|
|
53
|
-
|
|
54
|
-
raise ValueError(f"在筛选组 {group_index} 中未找到 '{text}',有效值: {options}")
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def _convert_filters(filter_opt: FilterOption) -> list[tuple[int, str]]:
|
|
58
|
-
"""将 FilterOption 转换为内部 (filters_index, text) 列表。"""
|
|
59
|
-
result: list[tuple[int, str]] = []
|
|
60
|
-
|
|
61
|
-
if filter_opt.sort_by:
|
|
62
|
-
result.append(_find_internal_option(1, filter_opt.sort_by))
|
|
63
|
-
if filter_opt.note_type:
|
|
64
|
-
result.append(_find_internal_option(2, filter_opt.note_type))
|
|
65
|
-
if filter_opt.publish_time:
|
|
66
|
-
result.append(_find_internal_option(3, filter_opt.publish_time))
|
|
67
|
-
if filter_opt.search_scope:
|
|
68
|
-
result.append(_find_internal_option(4, filter_opt.search_scope))
|
|
69
|
-
if filter_opt.location:
|
|
70
|
-
result.append(_find_internal_option(5, filter_opt.location))
|
|
71
|
-
|
|
72
|
-
return result
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def search_feeds(
|
|
76
|
-
page: Page,
|
|
77
|
-
keyword: str,
|
|
78
|
-
filter_option: FilterOption | None = None,
|
|
79
|
-
) -> list[Feed]:
|
|
80
|
-
"""搜索 Feeds。
|
|
81
|
-
|
|
82
|
-
Args:
|
|
83
|
-
page: CDP 页面对象。
|
|
84
|
-
keyword: 搜索关键词。
|
|
85
|
-
filter_option: 可选筛选条件。
|
|
86
|
-
|
|
87
|
-
Raises:
|
|
88
|
-
NoFeedsError: 没有捕获到搜索结果。
|
|
89
|
-
ValueError: 筛选选项无效。
|
|
90
|
-
"""
|
|
91
|
-
search_url = make_search_url(keyword)
|
|
92
|
-
page.navigate(search_url)
|
|
93
|
-
page.wait_for_load()
|
|
94
|
-
page.wait_dom_stable()
|
|
95
|
-
|
|
96
|
-
# 等待 __INITIAL_STATE__.search.feeds 有数据
|
|
97
|
-
_wait_for_search_feeds(page)
|
|
98
|
-
|
|
99
|
-
# 应用筛选条件(若有)
|
|
100
|
-
if filter_option:
|
|
101
|
-
internal_filters = _convert_filters(filter_option)
|
|
102
|
-
if internal_filters:
|
|
103
|
-
_apply_filters(page, internal_filters)
|
|
104
|
-
|
|
105
|
-
# 提取搜索结果
|
|
106
|
-
result = page.evaluate(_EXTRACT_SEARCH_JS)
|
|
107
|
-
if not result:
|
|
108
|
-
raise NoFeedsError()
|
|
109
|
-
|
|
110
|
-
feeds_data = json.loads(result)
|
|
111
|
-
if not feeds_data:
|
|
112
|
-
raise NoFeedsError()
|
|
113
|
-
|
|
114
|
-
return [Feed.from_dict(f) for f in feeds_data]
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def _wait_for_search_feeds(page: Page, timeout: float = 15.0) -> None:
|
|
118
|
-
"""等待 __INITIAL_STATE__.search.feeds 有数据。
|
|
119
|
-
|
|
120
|
-
Raises:
|
|
121
|
-
NoFeedsError: 超时仍无数据。
|
|
122
|
-
"""
|
|
123
|
-
deadline = time.monotonic() + timeout
|
|
124
|
-
while time.monotonic() < deadline:
|
|
125
|
-
result = page.evaluate(_EXTRACT_SEARCH_JS)
|
|
126
|
-
if result:
|
|
127
|
-
try:
|
|
128
|
-
if json.loads(result):
|
|
129
|
-
return
|
|
130
|
-
except json.JSONDecodeError:
|
|
131
|
-
pass
|
|
132
|
-
time.sleep(0.3)
|
|
133
|
-
raise NoFeedsError()
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def _apply_filters(page: Page, filters: list[tuple[int, str]]) -> None:
|
|
137
|
-
"""应用筛选条件。
|
|
138
|
-
|
|
139
|
-
在单次 evaluate 调用内完成:点击筛选按钮 → 等待面板 → 按文本点击各选项
|
|
140
|
-
→ 等待搜索结果刷新。
|
|
141
|
-
避免多次 WebSocket 连接导致面板关闭的时序问题。
|
|
142
|
-
"""
|
|
143
|
-
filter_js_list = ", ".join(
|
|
144
|
-
f'[{idx}, {json.dumps(text)}]' for idx, text in filters
|
|
145
|
-
)
|
|
146
|
-
|
|
147
|
-
# 记录当前 feeds 快照,用于检测结果是否已刷新
|
|
148
|
-
snapshot_js = "JSON.stringify(window.__INITIAL_STATE__?.search?.feeds?.value ?? window.__INITIAL_STATE__?.search?.feeds?._value ?? null)"
|
|
149
|
-
|
|
150
|
-
script = f"""
|
|
151
|
-
(() => {{
|
|
152
|
-
return new Promise((resolve, reject) => {{
|
|
153
|
-
const btn = document.querySelector('div.filter');
|
|
154
|
-
if (!btn) {{ reject('筛选按钮不存在'); return; }}
|
|
155
|
-
|
|
156
|
-
// 记录点击前的 feeds 快照(用于判断结果已刷新)
|
|
157
|
-
const snapshot = {snapshot_js};
|
|
158
|
-
btn.click();
|
|
159
|
-
|
|
160
|
-
const items = [{filter_js_list}];
|
|
161
|
-
let attempts = 0;
|
|
162
|
-
|
|
163
|
-
// 等待筛选面板出现
|
|
164
|
-
const panelTimer = setInterval(() => {{
|
|
165
|
-
const wrapper = document.querySelector('div.filters-wrapper');
|
|
166
|
-
if (!wrapper) {{
|
|
167
|
-
if (++attempts > 50) {{ clearInterval(panelTimer); reject('筛选面板等待超时'); }}
|
|
168
|
-
return;
|
|
169
|
-
}}
|
|
170
|
-
clearInterval(panelTimer);
|
|
171
|
-
|
|
172
|
-
// 依次点击各筛选项
|
|
173
|
-
for (const [groupIdx, text] of items) {{
|
|
174
|
-
const group = wrapper.querySelectorAll('div.filters')[groupIdx - 1];
|
|
175
|
-
if (!group) {{ reject('筛选组 ' + groupIdx + ' 不存在'); return; }}
|
|
176
|
-
const tag = Array.from(group.querySelectorAll('div.tags'))
|
|
177
|
-
.find(el => el.textContent.trim() === text);
|
|
178
|
-
if (!tag) {{ reject('选项不存在: ' + text); return; }}
|
|
179
|
-
tag.click();
|
|
180
|
-
}}
|
|
181
|
-
|
|
182
|
-
const selected = items.every(([groupIdx, text]) => {{
|
|
183
|
-
const group = wrapper.querySelectorAll('div.filters')[groupIdx - 1];
|
|
184
|
-
const tag = Array.from(group.querySelectorAll('div.tags'))
|
|
185
|
-
.find(el => el.textContent.trim() === text);
|
|
186
|
-
return tag && (
|
|
187
|
-
tag.classList.contains('active')
|
|
188
|
-
|| tag.classList.contains('selected')
|
|
189
|
-
|| tag.getAttribute('aria-selected') === 'true'
|
|
190
|
-
|| tag.querySelector('input:checked')
|
|
191
|
-
);
|
|
192
|
-
}});
|
|
193
|
-
if (!selected) {{ reject('筛选选项未全部生效'); return; }}
|
|
194
|
-
|
|
195
|
-
// 等待最终搜索结果刷新并稳定,忽略多筛选的中间响应。
|
|
196
|
-
let refreshAttempts = 0;
|
|
197
|
-
let last = snapshot;
|
|
198
|
-
let stableTicks = 0;
|
|
199
|
-
let changed = false;
|
|
200
|
-
const refreshTimer = setInterval(() => {{
|
|
201
|
-
const current = {snapshot_js};
|
|
202
|
-
if (current !== snapshot) changed = true;
|
|
203
|
-
if (changed && current === last) stableTicks++;
|
|
204
|
-
else stableTicks = 0;
|
|
205
|
-
last = current;
|
|
206
|
-
if (changed && stableTicks >= 8) {{
|
|
207
|
-
clearInterval(refreshTimer);
|
|
208
|
-
resolve(null);
|
|
209
|
-
return;
|
|
210
|
-
}}
|
|
211
|
-
if (++refreshAttempts > 60) {{
|
|
212
|
-
clearInterval(refreshTimer);
|
|
213
|
-
reject('筛选结果刷新超时');
|
|
214
|
-
}}
|
|
215
|
-
}}, 100);
|
|
216
|
-
}}, 100);
|
|
217
|
-
}});
|
|
218
|
-
}})()
|
|
219
|
-
"""
|
|
220
|
-
try:
|
|
221
|
-
page.evaluate(script)
|
|
222
|
-
except Exception as e:
|
|
223
|
-
raise ValueError(f"应用筛选失败: {e}") from e
|
|
224
|
-
|
|
225
|
-
# 等待 __INITIAL_STATE__ 中有新数据
|
|
226
|
-
_wait_for_search_feeds(page)
|