@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,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
- ]