@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,550 +0,0 @@
|
|
|
1
|
-
"""Feed 详情 + 评论加载,对应 Go xiaohongshu/feed_detail.go(867 行)。"""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
import logging
|
|
7
|
-
import random
|
|
8
|
-
import re
|
|
9
|
-
import time
|
|
10
|
-
import urllib.parse
|
|
11
|
-
|
|
12
|
-
from .cdp import Page
|
|
13
|
-
from .errors import NoFeedDetailError, PageNotAccessibleError
|
|
14
|
-
from .human import (
|
|
15
|
-
BUTTON_CLICK_INTERVAL,
|
|
16
|
-
DEFAULT_MAX_ATTEMPTS,
|
|
17
|
-
FINAL_SPRINT_PUSH_COUNT,
|
|
18
|
-
HUMAN_DELAY,
|
|
19
|
-
LARGE_SCROLL_TRIGGER,
|
|
20
|
-
MAX_CLICK_PER_ROUND,
|
|
21
|
-
MIN_SCROLL_DELTA,
|
|
22
|
-
POST_SCROLL,
|
|
23
|
-
READ_TIME,
|
|
24
|
-
SCROLL_WAIT,
|
|
25
|
-
SHORT_READ,
|
|
26
|
-
STAGNANT_LIMIT,
|
|
27
|
-
calculate_scroll_delta,
|
|
28
|
-
get_scroll_interval,
|
|
29
|
-
get_scroll_ratio,
|
|
30
|
-
sleep_random,
|
|
31
|
-
)
|
|
32
|
-
from .selectors import (
|
|
33
|
-
ACCESS_ERROR_WRAPPER,
|
|
34
|
-
END_CONTAINER,
|
|
35
|
-
NO_COMMENTS_TEXT,
|
|
36
|
-
PARENT_COMMENT,
|
|
37
|
-
SHOW_MORE_BUTTON,
|
|
38
|
-
)
|
|
39
|
-
from .types import (
|
|
40
|
-
CommentList,
|
|
41
|
-
CommentLoadConfig,
|
|
42
|
-
FeedDetail,
|
|
43
|
-
FeedDetailResponse,
|
|
44
|
-
)
|
|
45
|
-
from .urls import make_feed_detail_url
|
|
46
|
-
|
|
47
|
-
logger = logging.getLogger(__name__)
|
|
48
|
-
|
|
49
|
-
# 页面不可访问关键词
|
|
50
|
-
_INACCESSIBLE_KEYWORDS = [
|
|
51
|
-
"当前笔记暂时无法浏览",
|
|
52
|
-
"该内容因违规已被删除",
|
|
53
|
-
"该笔记已被删除",
|
|
54
|
-
"内容不存在",
|
|
55
|
-
"笔记不存在",
|
|
56
|
-
"已失效",
|
|
57
|
-
"私密笔记",
|
|
58
|
-
"仅作者可见",
|
|
59
|
-
"因用户设置,你无法查看",
|
|
60
|
-
"因违规无法查看",
|
|
61
|
-
"Isn't Available",
|
|
62
|
-
"isn't available",
|
|
63
|
-
]
|
|
64
|
-
|
|
65
|
-
# 扫码验证关键词(触发反爬机制)
|
|
66
|
-
_SCAN_QRCODE_KEYWORDS = [
|
|
67
|
-
"扫码查看",
|
|
68
|
-
"打开小红书App扫码",
|
|
69
|
-
"请使用小红书App扫码",
|
|
70
|
-
]
|
|
71
|
-
|
|
72
|
-
_REPLY_COUNT_RE = re.compile(r"展开\s*(\d+)\s*条回复")
|
|
73
|
-
_TOTAL_COMMENT_RE = re.compile(r"共(\d+)条评论")
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def get_feed_detail(
|
|
77
|
-
page: Page,
|
|
78
|
-
feed_id: str,
|
|
79
|
-
xsec_token: str,
|
|
80
|
-
xsec_source: str = "pc_feed",
|
|
81
|
-
load_all_comments: bool = False,
|
|
82
|
-
config: CommentLoadConfig | None = None,
|
|
83
|
-
keyword: str = "篮球",
|
|
84
|
-
) -> FeedDetailResponse:
|
|
85
|
-
"""获取 Feed 详情(含评论)。
|
|
86
|
-
|
|
87
|
-
Args:
|
|
88
|
-
page: CDP 页面对象。
|
|
89
|
-
feed_id: Feed ID。
|
|
90
|
-
xsec_token: xsec_token。
|
|
91
|
-
load_all_comments: 是否加载全部评论。
|
|
92
|
-
config: 评论加载配置。
|
|
93
|
-
|
|
94
|
-
Raises:
|
|
95
|
-
PageNotAccessibleError: 页面不可访问。
|
|
96
|
-
NoFeedDetailError: 未获取到详情数据。
|
|
97
|
-
"""
|
|
98
|
-
if config is None:
|
|
99
|
-
config = CommentLoadConfig()
|
|
100
|
-
|
|
101
|
-
url = make_feed_detail_url(feed_id, xsec_token, xsec_source)
|
|
102
|
-
logger.info("打开 feed 详情页: %s", url)
|
|
103
|
-
logger.info(
|
|
104
|
-
"配置: 点击更多=%s, 回复阈值=%d, 最大评论数=%d, 滚动速度=%s",
|
|
105
|
-
config.click_more_replies,
|
|
106
|
-
config.max_replies_threshold,
|
|
107
|
-
config.max_comment_items,
|
|
108
|
-
config.scroll_speed,
|
|
109
|
-
)
|
|
110
|
-
|
|
111
|
-
# 导航(含重试)
|
|
112
|
-
# 注:风控 302→/404 由扩展 auto-fix(自动从搜索页换新 token 重试),
|
|
113
|
-
# 此处只捕获真正的瞬态错误(网络超时等),风控拦截不重试。
|
|
114
|
-
for attempt in range(3):
|
|
115
|
-
try:
|
|
116
|
-
page.navigate(url)
|
|
117
|
-
page.wait_for_load()
|
|
118
|
-
page.wait_dom_stable()
|
|
119
|
-
break
|
|
120
|
-
except Exception as e:
|
|
121
|
-
err_str = str(e)
|
|
122
|
-
# 风控 302 重定向:扩展已尝试 auto-fix,仍失败说明 token/session 问题,直接抛出
|
|
123
|
-
if "风控拦截" in err_str or "重定向至" in err_str:
|
|
124
|
-
raise PageNotAccessibleError(f"笔记无法访问(风控/token): {err_str}") from e
|
|
125
|
-
logger.debug("页面导航重试 #%d: %s", attempt, e)
|
|
126
|
-
time.sleep(0.5 + random.random())
|
|
127
|
-
else:
|
|
128
|
-
raise RuntimeError("页面导航失败")
|
|
129
|
-
|
|
130
|
-
sleep_random(800, 1500)
|
|
131
|
-
|
|
132
|
-
# 模拟阅读鼠标轨迹(同步进行,增加行为真实性)
|
|
133
|
-
try:
|
|
134
|
-
page.simulate_reading_mouse(random.randint(2000, 4000))
|
|
135
|
-
except Exception:
|
|
136
|
-
pass
|
|
137
|
-
|
|
138
|
-
# 检查页面可访问性(扫码验证时自动等待重试)
|
|
139
|
-
_check_page_accessible(page, url, keyword)
|
|
140
|
-
|
|
141
|
-
# 加载全部评论
|
|
142
|
-
if load_all_comments:
|
|
143
|
-
try:
|
|
144
|
-
_load_all_comments(page, config)
|
|
145
|
-
except Exception as e:
|
|
146
|
-
logger.warning("加载全部评论失败: %s", e)
|
|
147
|
-
|
|
148
|
-
return _extract_feed_detail(page, feed_id)
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
# ========== 页面检查 ==========
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
def _check_page_accessible(page: Page, url: str = "", keyword: str = "篮球") -> None:
|
|
155
|
-
"""检查页面是否可访问。
|
|
156
|
-
|
|
157
|
-
风控绕过策略(三级重试):
|
|
158
|
-
1. 先回首页,再跳目标页(same-origin 导航)
|
|
159
|
-
2. 若仍触发:在搜索结果页加载后再跳目标页(模拟真实搜索→点击笔记流程)
|
|
160
|
-
3. 仍失败则抛出异常,提示用户手动扫码
|
|
161
|
-
"""
|
|
162
|
-
time.sleep(0.5)
|
|
163
|
-
|
|
164
|
-
text = page.get_element_text(ACCESS_ERROR_WRAPPER)
|
|
165
|
-
if not text:
|
|
166
|
-
return
|
|
167
|
-
|
|
168
|
-
text = text.strip()
|
|
169
|
-
|
|
170
|
-
if _is_scan_qrcode_verification(text):
|
|
171
|
-
raise PageNotAccessibleError("触发了小红书验证,请重新连接账号或稍后手动完成验证")
|
|
172
|
-
|
|
173
|
-
for kw in _INACCESSIBLE_KEYWORDS:
|
|
174
|
-
if kw in text:
|
|
175
|
-
raise PageNotAccessibleError(kw)
|
|
176
|
-
|
|
177
|
-
if text:
|
|
178
|
-
raise PageNotAccessibleError(text)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
def _is_scan_qrcode_verification(text: str) -> bool:
|
|
182
|
-
"""判断页面文本是否为扫码验证。"""
|
|
183
|
-
return any(kw in text for kw in _SCAN_QRCODE_KEYWORDS)
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
# ========== 数据提取 ==========
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
_EXTRACT_STATE_JS = """
|
|
190
|
-
(() => {
|
|
191
|
-
if (window.__INITIAL_STATE__ &&
|
|
192
|
-
window.__INITIAL_STATE__.note &&
|
|
193
|
-
window.__INITIAL_STATE__.note.noteDetailMap) {
|
|
194
|
-
return JSON.stringify(window.__INITIAL_STATE__.note.noteDetailMap);
|
|
195
|
-
}
|
|
196
|
-
return "";
|
|
197
|
-
})()
|
|
198
|
-
"""
|
|
199
|
-
|
|
200
|
-
_EXTRACT_DOM_BODY_JS = """
|
|
201
|
-
(() => {
|
|
202
|
-
const bodyEl = document.querySelector('#detail-desc');
|
|
203
|
-
if (!bodyEl) return null;
|
|
204
|
-
// 先提取话题标签
|
|
205
|
-
const tags = Array.from(bodyEl.querySelectorAll('a.tag'))
|
|
206
|
-
.map(a => a.textContent.trim())
|
|
207
|
-
.filter(Boolean);
|
|
208
|
-
// 克隆后移除 a.tag,再取 textContent(不依赖 CSS layout,后台标签页同样生效)
|
|
209
|
-
const clone = bodyEl.cloneNode(true);
|
|
210
|
-
clone.querySelectorAll('a.tag').forEach(el => el.remove());
|
|
211
|
-
const bodyText = clone.textContent.replace(/\\n{3,}/g, '\\n\\n').trim();
|
|
212
|
-
if (!bodyText && !tags.length) return null;
|
|
213
|
-
return {body: bodyText, tags: tags};
|
|
214
|
-
})()
|
|
215
|
-
"""
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
def _extract_feed_detail(page: Page, feed_id: str) -> FeedDetailResponse:
|
|
219
|
-
"""从 __INITIAL_STATE__ 提取 Feed 详情,轮询最多 10s。
|
|
220
|
-
|
|
221
|
-
两阶段提取:
|
|
222
|
-
1. 等待 __INITIAL_STATE__ 数据就绪(最多 10s)
|
|
223
|
-
2. 等待 #detail-desc DOM 渲染完成后提取正文(最多 8s)
|
|
224
|
-
Vue 渲染时序:state 早于 DOM,需分开轮询。
|
|
225
|
-
"""
|
|
226
|
-
# 阶段 1:等待 __INITIAL_STATE__
|
|
227
|
-
deadline = time.monotonic() + 10.0
|
|
228
|
-
result = None
|
|
229
|
-
while time.monotonic() < deadline:
|
|
230
|
-
result = page.evaluate(_EXTRACT_STATE_JS)
|
|
231
|
-
if result:
|
|
232
|
-
break
|
|
233
|
-
time.sleep(0.3)
|
|
234
|
-
|
|
235
|
-
if not result:
|
|
236
|
-
raise NoFeedDetailError()
|
|
237
|
-
|
|
238
|
-
note_detail_map = json.loads(result)
|
|
239
|
-
note_data = note_detail_map.get(feed_id)
|
|
240
|
-
if not note_data:
|
|
241
|
-
raise NoFeedDetailError()
|
|
242
|
-
|
|
243
|
-
# 阶段 2:等待 #detail-desc 渲染(Vue 异步渲染,state 就绪后 DOM 可能还未填充)
|
|
244
|
-
dom_deadline = time.monotonic() + 8.0
|
|
245
|
-
dom_result = None
|
|
246
|
-
while time.monotonic() < dom_deadline:
|
|
247
|
-
dom_result = page.evaluate(_EXTRACT_DOM_BODY_JS)
|
|
248
|
-
if dom_result and isinstance(dom_result, dict) and dom_result.get("body", "").strip():
|
|
249
|
-
break
|
|
250
|
-
time.sleep(0.3)
|
|
251
|
-
|
|
252
|
-
note = note_data.get("note", {})
|
|
253
|
-
if dom_result and isinstance(dom_result, dict):
|
|
254
|
-
note["_domBody"] = dom_result.get("body", "")
|
|
255
|
-
note["_domTags"] = dom_result.get("tags", [])
|
|
256
|
-
|
|
257
|
-
return FeedDetailResponse(
|
|
258
|
-
note=FeedDetail.from_dict(note),
|
|
259
|
-
comments=CommentList.from_dict(note_data.get("comments", {})),
|
|
260
|
-
)
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
# ========== 评论加载状态机 ==========
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
def _load_all_comments(page: Page, config: CommentLoadConfig) -> None:
|
|
267
|
-
"""加载全部评论的状态机。"""
|
|
268
|
-
max_attempts = (
|
|
269
|
-
config.max_comment_items * 3 if config.max_comment_items > 0 else DEFAULT_MAX_ATTEMPTS
|
|
270
|
-
)
|
|
271
|
-
scroll_interval = get_scroll_interval(config.scroll_speed)
|
|
272
|
-
|
|
273
|
-
logger.info("开始加载评论...")
|
|
274
|
-
_scroll_to_comments_area(page)
|
|
275
|
-
sleep_random(*HUMAN_DELAY)
|
|
276
|
-
|
|
277
|
-
# 检查是否无评论
|
|
278
|
-
if _check_no_comments(page):
|
|
279
|
-
logger.info("检测到无评论区域,跳过加载")
|
|
280
|
-
return
|
|
281
|
-
|
|
282
|
-
# 状态
|
|
283
|
-
last_count = 0
|
|
284
|
-
last_scroll_top = 0
|
|
285
|
-
stagnant_checks = 0
|
|
286
|
-
total_clicked = 0
|
|
287
|
-
total_skipped = 0
|
|
288
|
-
|
|
289
|
-
for attempt in range(max_attempts):
|
|
290
|
-
logger.debug("=== 尝试 %d/%d ===", attempt + 1, max_attempts)
|
|
291
|
-
|
|
292
|
-
# 一次调用同时获取:评论数 + 是否到底
|
|
293
|
-
state = _check_page_state(page)
|
|
294
|
-
|
|
295
|
-
if state["at_end"]:
|
|
296
|
-
logger.info(
|
|
297
|
-
"检测到 THE END,加载完成: %d 条评论, 点击: %d, 跳过: %d",
|
|
298
|
-
state["count"],
|
|
299
|
-
total_clicked,
|
|
300
|
-
total_skipped,
|
|
301
|
-
)
|
|
302
|
-
return
|
|
303
|
-
|
|
304
|
-
# 定期点击展开按钮
|
|
305
|
-
if config.click_more_replies and attempt % BUTTON_CLICK_INTERVAL == 0:
|
|
306
|
-
clicked, skipped = _click_show_more_buttons(page, config.max_replies_threshold)
|
|
307
|
-
total_clicked += clicked
|
|
308
|
-
total_skipped += skipped
|
|
309
|
-
if clicked > 0 or skipped > 0:
|
|
310
|
-
sleep_random(*READ_TIME)
|
|
311
|
-
# 第二轮
|
|
312
|
-
c2, s2 = _click_show_more_buttons(page, config.max_replies_threshold)
|
|
313
|
-
total_clicked += c2
|
|
314
|
-
total_skipped += s2
|
|
315
|
-
if c2 > 0 or s2 > 0:
|
|
316
|
-
sleep_random(*SHORT_READ)
|
|
317
|
-
|
|
318
|
-
current_count = state["count"]
|
|
319
|
-
if current_count != last_count:
|
|
320
|
-
logger.info("评论增加: %d -> %d", last_count, current_count)
|
|
321
|
-
last_count = current_count
|
|
322
|
-
stagnant_checks = 0
|
|
323
|
-
else:
|
|
324
|
-
stagnant_checks += 1
|
|
325
|
-
|
|
326
|
-
# 检查是否达到目标
|
|
327
|
-
if config.max_comment_items > 0 and current_count >= config.max_comment_items:
|
|
328
|
-
logger.info("已达到目标评论数: %d/%d", current_count, config.max_comment_items)
|
|
329
|
-
return
|
|
330
|
-
|
|
331
|
-
# 滚动
|
|
332
|
-
if current_count > 0:
|
|
333
|
-
_scroll_to_last_comment(page)
|
|
334
|
-
sleep_random(*POST_SCROLL)
|
|
335
|
-
|
|
336
|
-
large_mode = stagnant_checks >= LARGE_SCROLL_TRIGGER
|
|
337
|
-
push_count = 1
|
|
338
|
-
if large_mode:
|
|
339
|
-
push_count = 3 + random.randint(0, 2)
|
|
340
|
-
|
|
341
|
-
scroll_delta, current_scroll_top = _human_scroll(
|
|
342
|
-
page, config.scroll_speed, large_mode, push_count
|
|
343
|
-
)
|
|
344
|
-
|
|
345
|
-
if scroll_delta < MIN_SCROLL_DELTA or current_scroll_top == last_scroll_top:
|
|
346
|
-
stagnant_checks += 1
|
|
347
|
-
else:
|
|
348
|
-
stagnant_checks = 0
|
|
349
|
-
last_scroll_top = current_scroll_top
|
|
350
|
-
|
|
351
|
-
# 停滞处理
|
|
352
|
-
if stagnant_checks >= STAGNANT_LIMIT:
|
|
353
|
-
logger.info("停滞过多,尝试大冲刺...")
|
|
354
|
-
_human_scroll(page, config.scroll_speed, True, 10)
|
|
355
|
-
stagnant_checks = 0
|
|
356
|
-
|
|
357
|
-
time.sleep(scroll_interval)
|
|
358
|
-
|
|
359
|
-
# 最终冲刺
|
|
360
|
-
logger.info("达到最大尝试次数,最后冲刺...")
|
|
361
|
-
_human_scroll(page, config.scroll_speed, True, FINAL_SPRINT_PUSH_COUNT)
|
|
362
|
-
count = _get_comment_count(page)
|
|
363
|
-
logger.info("加载结束: %d 条评论, 点击: %d, 跳过: %d", count, total_clicked, total_skipped)
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
# ========== 滚动 ==========
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
def _human_scroll(
|
|
370
|
-
page: Page,
|
|
371
|
-
speed: str,
|
|
372
|
-
large_mode: bool,
|
|
373
|
-
push_count: int,
|
|
374
|
-
) -> tuple[int, int]:
|
|
375
|
-
"""人类化滚动。
|
|
376
|
-
|
|
377
|
-
Returns:
|
|
378
|
-
(actual_delta, current_scroll_top)
|
|
379
|
-
"""
|
|
380
|
-
# 一次 evaluate 同时获取 scrollTop 和 viewportHeight,减少 bridge 往返
|
|
381
|
-
state = page.evaluate(
|
|
382
|
-
"({scrollTop: window.pageYOffset || document.documentElement.scrollTop || 0,"
|
|
383
|
-
" viewportHeight: window.innerHeight})"
|
|
384
|
-
)
|
|
385
|
-
before_top = int(state.get("scrollTop", 0)) if isinstance(state, dict) else page.get_scroll_top()
|
|
386
|
-
viewport_height = int(state.get("viewportHeight", 768)) if isinstance(state, dict) else page.get_viewport_height()
|
|
387
|
-
|
|
388
|
-
base_ratio = get_scroll_ratio(speed)
|
|
389
|
-
if large_mode:
|
|
390
|
-
base_ratio *= 2.0
|
|
391
|
-
|
|
392
|
-
actual_delta = 0
|
|
393
|
-
current_scroll_top = before_top
|
|
394
|
-
|
|
395
|
-
for i in range(max(1, push_count)):
|
|
396
|
-
scroll_delta = calculate_scroll_delta(viewport_height, base_ratio)
|
|
397
|
-
page.scroll_by(0, int(scroll_delta))
|
|
398
|
-
sleep_random(*SCROLL_WAIT)
|
|
399
|
-
|
|
400
|
-
current_scroll_top = page.get_scroll_top()
|
|
401
|
-
delta_this = current_scroll_top - before_top
|
|
402
|
-
actual_delta += delta_this
|
|
403
|
-
before_top = current_scroll_top
|
|
404
|
-
|
|
405
|
-
if i < push_count - 1:
|
|
406
|
-
sleep_random(*HUMAN_DELAY)
|
|
407
|
-
|
|
408
|
-
# 如果没有滚动,强制到底部
|
|
409
|
-
if actual_delta < MIN_SCROLL_DELTA and push_count > 0:
|
|
410
|
-
page.scroll_to_bottom()
|
|
411
|
-
sleep_random(*POST_SCROLL)
|
|
412
|
-
current_scroll_top = page.get_scroll_top()
|
|
413
|
-
actual_delta = current_scroll_top - (before_top - actual_delta)
|
|
414
|
-
|
|
415
|
-
return actual_delta, current_scroll_top
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
def _scroll_to_comments_area(page: Page) -> None:
|
|
419
|
-
"""滚动到评论区。"""
|
|
420
|
-
logger.info("滚动到评论区...")
|
|
421
|
-
page.scroll_element_into_view(".comments-container")
|
|
422
|
-
time.sleep(0.5)
|
|
423
|
-
# 触发懒加载
|
|
424
|
-
page.dispatch_wheel_event(100)
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
def _scroll_to_last_comment(page: Page) -> None:
|
|
428
|
-
"""滚动到最后一条评论(count + scroll 合并为 1 次 evaluate)。"""
|
|
429
|
-
sel = json.dumps(PARENT_COMMENT)
|
|
430
|
-
page.evaluate(
|
|
431
|
-
f"(function(){{const els=document.querySelectorAll({sel});"
|
|
432
|
-
f"if(els.length>0)els[els.length-1].scrollIntoView({{block:'center',behavior:'smooth'}})}})()"
|
|
433
|
-
)
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
# ========== DOM 查询 ==========
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
def _get_comment_count(page: Page) -> int:
|
|
440
|
-
"""获取当前评论数量。"""
|
|
441
|
-
return page.get_elements_count(PARENT_COMMENT)
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
def _get_total_comment_count(page: Page) -> int:
|
|
445
|
-
"""获取总评论数(从 "共N条评论" 提取)。"""
|
|
446
|
-
text = page.get_element_text(".comments-container .total")
|
|
447
|
-
if not text:
|
|
448
|
-
return 0
|
|
449
|
-
match = _TOTAL_COMMENT_RE.search(text)
|
|
450
|
-
if match:
|
|
451
|
-
return int(match.group(1))
|
|
452
|
-
return 0
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
def _check_no_comments(page: Page) -> bool:
|
|
456
|
-
"""检查是否无评论区域。"""
|
|
457
|
-
text = page.get_element_text(NO_COMMENTS_TEXT)
|
|
458
|
-
if not text:
|
|
459
|
-
return False
|
|
460
|
-
return "这是一片荒地" in text.strip()
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
def _check_end_container(page: Page) -> bool:
|
|
464
|
-
"""检查是否到达底部 THE END。"""
|
|
465
|
-
text = page.get_element_text(END_CONTAINER)
|
|
466
|
-
if not text:
|
|
467
|
-
return False
|
|
468
|
-
upper = text.strip().upper()
|
|
469
|
-
return "THE END" in upper or "THEEND" in upper
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
def _check_page_state(page: Page) -> dict:
|
|
473
|
-
"""一次 evaluate 同时获取评论数、是否无评论、是否到达底部。
|
|
474
|
-
|
|
475
|
-
Returns:
|
|
476
|
-
{"count": int, "no_comments": bool, "at_end": bool}
|
|
477
|
-
"""
|
|
478
|
-
sel_parent = json.dumps(PARENT_COMMENT)
|
|
479
|
-
sel_no = json.dumps(NO_COMMENTS_TEXT)
|
|
480
|
-
sel_end = json.dumps(END_CONTAINER)
|
|
481
|
-
result = page.evaluate(
|
|
482
|
-
f"(function(){{"
|
|
483
|
-
f" var count = document.querySelectorAll({sel_parent}).length;"
|
|
484
|
-
f" var noText = (document.querySelector({sel_no}) || {{}}).textContent || '';"
|
|
485
|
-
f" var endText = ((document.querySelector({sel_end}) || {{}}).textContent || '').toUpperCase();"
|
|
486
|
-
f" return {{count: count,"
|
|
487
|
-
f" no_comments: noText.indexOf('这是一片荒地') >= 0,"
|
|
488
|
-
f" at_end: endText.indexOf('THE END') >= 0 || endText.indexOf('THEEND') >= 0}};"
|
|
489
|
-
f"}})()"
|
|
490
|
-
)
|
|
491
|
-
if isinstance(result, dict):
|
|
492
|
-
return result
|
|
493
|
-
return {"count": 0, "no_comments": False, "at_end": False}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
# ========== 按钮点击 ==========
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
def _click_show_more_buttons(page: Page, max_threshold: int) -> tuple[int, int]:
|
|
500
|
-
"""点击"展开N条回复"按钮。
|
|
501
|
-
|
|
502
|
-
优化:1 次 evaluate 批量获取所有按钮文本,每次点击用 1 次 evaluate 完成
|
|
503
|
-
scroll+click,减少 bridge 往返次数。
|
|
504
|
-
|
|
505
|
-
Returns:
|
|
506
|
-
(clicked, skipped)
|
|
507
|
-
"""
|
|
508
|
-
sel = json.dumps(SHOW_MORE_BUTTON)
|
|
509
|
-
|
|
510
|
-
# 一次 evaluate 获取所有按钮文本
|
|
511
|
-
texts = page.evaluate(
|
|
512
|
-
f"Array.from(document.querySelectorAll({sel})).map(e => e.textContent || '')"
|
|
513
|
-
)
|
|
514
|
-
if not texts or not isinstance(texts, list):
|
|
515
|
-
return 0, 0
|
|
516
|
-
|
|
517
|
-
max_click = MAX_CLICK_PER_ROUND + random.randint(0, MAX_CLICK_PER_ROUND - 1)
|
|
518
|
-
clicked = 0
|
|
519
|
-
skipped = 0
|
|
520
|
-
|
|
521
|
-
for i, text in enumerate(texts):
|
|
522
|
-
if clicked >= max_click:
|
|
523
|
-
break
|
|
524
|
-
|
|
525
|
-
if not text:
|
|
526
|
-
continue
|
|
527
|
-
|
|
528
|
-
# 检查是否应该跳过
|
|
529
|
-
if max_threshold > 0:
|
|
530
|
-
match = _REPLY_COUNT_RE.search(text)
|
|
531
|
-
if match:
|
|
532
|
-
reply_count = int(match.group(1))
|
|
533
|
-
if reply_count > max_threshold:
|
|
534
|
-
logger.debug(
|
|
535
|
-
"跳过 '%s'(回复数 %d > 阈值 %d)", text, reply_count, max_threshold
|
|
536
|
-
)
|
|
537
|
-
skipped += 1
|
|
538
|
-
continue
|
|
539
|
-
|
|
540
|
-
# 一次 evaluate 完成:滚动到按钮 + 点击
|
|
541
|
-
page.evaluate(
|
|
542
|
-
f"(function(){{"
|
|
543
|
-
f" var el=document.querySelectorAll({sel})[{i}];"
|
|
544
|
-
f" if(el){{el.scrollIntoView({{block:'center',behavior:'smooth'}});el.click();}}"
|
|
545
|
-
f"}})()"
|
|
546
|
-
)
|
|
547
|
-
sleep_random(*READ_TIME)
|
|
548
|
-
clicked += 1
|
|
549
|
-
|
|
550
|
-
return clicked, skipped
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
"""首页 Feed 列表,对应 Go xiaohongshu/feeds.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 .types import Feed
|
|
12
|
-
from .urls import HOME_URL
|
|
13
|
-
|
|
14
|
-
logger = logging.getLogger(__name__)
|
|
15
|
-
|
|
16
|
-
# 从 __INITIAL_STATE__ 提取 feeds 的 JS
|
|
17
|
-
_EXTRACT_FEEDS_JS = """
|
|
18
|
-
(() => {
|
|
19
|
-
if (window.__INITIAL_STATE__ &&
|
|
20
|
-
window.__INITIAL_STATE__.feed &&
|
|
21
|
-
window.__INITIAL_STATE__.feed.feeds) {
|
|
22
|
-
const feeds = window.__INITIAL_STATE__.feed.feeds;
|
|
23
|
-
const feedsData = feeds.value !== undefined ? feeds.value : feeds._value;
|
|
24
|
-
if (feedsData) {
|
|
25
|
-
return JSON.stringify(feedsData);
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
return "";
|
|
29
|
-
})()
|
|
30
|
-
"""
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def list_feeds(page: Page) -> list[Feed]:
|
|
34
|
-
"""获取首页 Feed 列表。
|
|
35
|
-
|
|
36
|
-
Raises:
|
|
37
|
-
NoFeedsError: 没有捕获到 feeds 数据。
|
|
38
|
-
"""
|
|
39
|
-
page.navigate(HOME_URL)
|
|
40
|
-
page.wait_for_load()
|
|
41
|
-
page.wait_dom_stable()
|
|
42
|
-
time.sleep(1)
|
|
43
|
-
|
|
44
|
-
result = page.evaluate(_EXTRACT_FEEDS_JS)
|
|
45
|
-
if not result:
|
|
46
|
-
raise NoFeedsError()
|
|
47
|
-
|
|
48
|
-
feeds_data = json.loads(result)
|
|
49
|
-
return [Feed.from_dict(f) for f in feeds_data]
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
"""人类行为模拟参数(延迟、滚动、悬停),对应 Go feed_detail.go 中的常量。"""
|
|
2
|
-
|
|
3
|
-
import random
|
|
4
|
-
import time
|
|
5
|
-
|
|
6
|
-
# ========== 配置常量 ==========
|
|
7
|
-
DEFAULT_MAX_ATTEMPTS = 500
|
|
8
|
-
STAGNANT_LIMIT = 20
|
|
9
|
-
MIN_SCROLL_DELTA = 10
|
|
10
|
-
MAX_CLICK_PER_ROUND = 3
|
|
11
|
-
STAGNANT_CHECK_THRESHOLD = 2
|
|
12
|
-
LARGE_SCROLL_TRIGGER = 5
|
|
13
|
-
BUTTON_CLICK_INTERVAL = 3
|
|
14
|
-
FINAL_SPRINT_PUSH_COUNT = 15
|
|
15
|
-
|
|
16
|
-
# ========== 延迟范围(毫秒) ==========
|
|
17
|
-
HUMAN_DELAY = (300, 700)
|
|
18
|
-
REACTION_TIME = (300, 800)
|
|
19
|
-
HOVER_TIME = (100, 300)
|
|
20
|
-
READ_TIME = (500, 1200)
|
|
21
|
-
SHORT_READ = (600, 1200)
|
|
22
|
-
SCROLL_WAIT = (100, 200)
|
|
23
|
-
POST_SCROLL = (300, 500)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def sleep_random(min_ms: int, max_ms: int) -> None:
|
|
27
|
-
"""随机延迟。"""
|
|
28
|
-
if max_ms <= min_ms:
|
|
29
|
-
time.sleep(min_ms / 1000.0)
|
|
30
|
-
return
|
|
31
|
-
delay = random.randint(min_ms, max_ms) / 1000.0
|
|
32
|
-
time.sleep(delay)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def navigation_delay() -> None:
|
|
36
|
-
"""页面导航后的随机等待,模拟人类阅读。"""
|
|
37
|
-
sleep_random(1000, 2500)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def get_scroll_interval(speed: str) -> float:
|
|
41
|
-
"""根据速度获取滚动间隔(秒)。"""
|
|
42
|
-
if speed == "slow":
|
|
43
|
-
return (1200 + random.randint(0, 300)) / 1000.0
|
|
44
|
-
if speed == "fast":
|
|
45
|
-
return (300 + random.randint(0, 100)) / 1000.0
|
|
46
|
-
# normal
|
|
47
|
-
return (600 + random.randint(0, 200)) / 1000.0
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def get_scroll_ratio(speed: str) -> float:
|
|
51
|
-
"""根据速度获取滚动比例。"""
|
|
52
|
-
if speed == "slow":
|
|
53
|
-
return 0.5
|
|
54
|
-
if speed == "fast":
|
|
55
|
-
return 0.9
|
|
56
|
-
return 0.7
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def calculate_scroll_delta(viewport_height: int, base_ratio: float) -> float:
|
|
60
|
-
"""计算滚动距离。"""
|
|
61
|
-
scroll_delta = viewport_height * (base_ratio + random.random() * 0.2)
|
|
62
|
-
if scroll_delta < 400:
|
|
63
|
-
scroll_delta = 400.0
|
|
64
|
-
return scroll_delta + random.randint(-50, 50)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
# 页面不可访问关键词
|
|
68
|
-
INACCESSIBLE_KEYWORDS = [
|
|
69
|
-
"当前笔记暂时无法浏览",
|
|
70
|
-
"该内容因违规已被删除",
|
|
71
|
-
"该笔记已被删除",
|
|
72
|
-
"内容不存在",
|
|
73
|
-
"笔记不存在",
|
|
74
|
-
"已失效",
|
|
75
|
-
"私密笔记",
|
|
76
|
-
"仅作者可见",
|
|
77
|
-
"因用户设置,你无法查看",
|
|
78
|
-
"因违规无法查看",
|
|
79
|
-
]
|