- 新增小红书前端模块(启动服务、内容分析、爆款创作、发布记录、技能卡片、拟人设置、AI 助手) - 新增 xhs-agent-desktop 独立桌面 Agent,对接 OMS 任务调度 - 登录页重构,整合微信登录与 OMS JWT 认证 - 订单、渠道、用户中心等页面与 API 增强 Co-authored-by: Cursor <cursoragent@cursor.com>
164 lines
5.1 KiB
Python
164 lines
5.1 KiB
Python
"""当前登录账号主页:加载笔记列表(含图文与视频)。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
import time
|
|
from typing import Callable
|
|
|
|
from .browse_feeds import _emit_progress
|
|
from .cdp import Page
|
|
from .errors import is_cancelled
|
|
from .human import get_scroll_interval, human_scroll_page, sleep_random
|
|
from .login import check_login_status
|
|
from .selectors import USER_PROFILE_NAV_LINK
|
|
from .types import Feed, UserBasicInfo, UserInteraction, UserProfileResponse
|
|
from .urls import EXPLORE_URL
|
|
from .user_profile import (
|
|
_EXTRACT_USER_DATA_JS,
|
|
_EXTRACT_USER_NOTES_JS,
|
|
_wait_for_initial_state,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
ProgressFn = Callable[[str], None]
|
|
|
|
_MIN_SCROLL_DELTA = 80
|
|
_STAGNANT_LIMIT = 3
|
|
|
|
|
|
def resolve_current_profile_url(page: Page) -> str:
|
|
"""从导航栏「我」解析个人主页完整 URL。"""
|
|
page.navigate(EXPLORE_URL)
|
|
page.wait_for_load()
|
|
if not check_login_status(page):
|
|
raise RuntimeError("未登录,请先在 Chrome 中登录小红书")
|
|
href = page.evaluate(
|
|
f"document.querySelector({json.dumps(USER_PROFILE_NAV_LINK)})"
|
|
f"?.getAttribute('href') || ''"
|
|
)
|
|
if not href or "/user/profile/" not in str(href):
|
|
raise RuntimeError("未找到个人主页链接,请确认已登录")
|
|
href = str(href).strip()
|
|
if href.startswith("http"):
|
|
return href
|
|
return f"https://www.xiaohongshu.com{href}"
|
|
|
|
|
|
def _parse_notes_payload(notes_result: str) -> list[Feed]:
|
|
notes_feeds_raw = json.loads(notes_result)
|
|
feeds: list[Feed] = []
|
|
for feed_group in notes_feeds_raw:
|
|
if isinstance(feed_group, list):
|
|
for f in feed_group:
|
|
if isinstance(f, dict):
|
|
feeds.append(Feed.from_dict(f))
|
|
elif isinstance(feed_group, dict):
|
|
feeds.append(Feed.from_dict(feed_group))
|
|
return feeds
|
|
|
|
|
|
def _snapshot_profile(page: Page) -> UserProfileResponse:
|
|
user_data_result = page.evaluate(_EXTRACT_USER_DATA_JS)
|
|
if not user_data_result:
|
|
raise RuntimeError("无法读取个人主页资料,请确认页面已加载")
|
|
|
|
notes_result = page.evaluate(_EXTRACT_USER_NOTES_JS)
|
|
if not notes_result:
|
|
raise RuntimeError("无法读取个人笔记列表,请确认主页笔记 Tab 已展示")
|
|
|
|
user_page_data = json.loads(user_data_result)
|
|
basic_info = UserBasicInfo.from_dict(user_page_data.get("basicInfo", {}))
|
|
interactions = [
|
|
UserInteraction.from_dict(i) for i in user_page_data.get("interactions", [])
|
|
]
|
|
feeds = _parse_notes_payload(notes_result)
|
|
return UserProfileResponse(
|
|
user_basic_info=basic_info,
|
|
interactions=interactions,
|
|
feeds=feeds,
|
|
)
|
|
|
|
|
|
def _merge_feeds(into: dict[str, Feed], feeds: list[Feed]) -> int:
|
|
added = 0
|
|
for feed in feeds:
|
|
fid = str(feed.id or "").strip()
|
|
if not fid or fid in into:
|
|
continue
|
|
into[fid] = feed
|
|
added += 1
|
|
return added
|
|
|
|
|
|
def list_my_account_feeds(
|
|
page: Page,
|
|
*,
|
|
scroll_speed: str = "normal",
|
|
max_scroll_rounds: int = 12,
|
|
on_progress: ProgressFn = None,
|
|
) -> tuple[UserProfileResponse, bool, str]:
|
|
"""打开当前账号主页,滚动加载笔记(图文+视频)。
|
|
|
|
Returns:
|
|
(profile, cancelled, profile_url)
|
|
"""
|
|
profile_url = resolve_current_profile_url(page)
|
|
_emit_progress(on_progress, "正在打开我的主页…")
|
|
page.navigate(profile_url)
|
|
page.wait_for_load()
|
|
page.wait_dom_stable()
|
|
_wait_for_initial_state(page)
|
|
|
|
by_id: dict[str, Feed] = {}
|
|
profile = _snapshot_profile(page)
|
|
_merge_feeds(by_id, profile.feeds)
|
|
_emit_progress(on_progress, f"我的主页:已读取 {len(by_id)} 篇笔记")
|
|
|
|
stagnant = 0
|
|
last_top = page.get_scroll_top()
|
|
for attempt in range(max_scroll_rounds):
|
|
if is_cancelled():
|
|
_emit_progress(on_progress, "我的主页:已停止")
|
|
profile.feeds = list(by_id.values())
|
|
return profile, True, profile_url
|
|
|
|
_emit_progress(on_progress, f"我的主页:滚动加载 ({attempt + 1})…")
|
|
actual_delta, current_top = human_scroll_page(
|
|
page,
|
|
scroll_speed,
|
|
False,
|
|
1,
|
|
)
|
|
sleep_random(0.3, 0.8)
|
|
time.sleep(get_scroll_interval(scroll_speed))
|
|
|
|
snap = _snapshot_profile(page)
|
|
added = _merge_feeds(by_id, snap.feeds)
|
|
if added:
|
|
stagnant = 0
|
|
_emit_progress(on_progress, f"我的主页:已读取 {len(by_id)} 篇笔记")
|
|
else:
|
|
stagnant += 1
|
|
|
|
if actual_delta < _MIN_SCROLL_DELTA or current_top == last_top:
|
|
stagnant += 1
|
|
else:
|
|
stagnant = max(0, stagnant - 1)
|
|
last_top = current_top
|
|
|
|
if stagnant >= _STAGNANT_LIMIT:
|
|
_emit_progress(on_progress, "我的主页:已滑到底部")
|
|
break
|
|
|
|
profile.feeds = list(by_id.values())
|
|
return profile, False, profile_url
|
|
|
|
|
|
def profile_url_user_id(url: str) -> str:
|
|
m = re.search(r"/user/profile/([a-fA-F0-9]+)", url)
|
|
return m.group(1) if m else ""
|