"""搜索 Feeds,对应 Go xiaohongshu/search.go。""" from __future__ import annotations import logging from .browse_feeds import human_browse_feed_list from .browse_plan_session import BrowsePlanSession from .cdp import Page from .collect_buffer import is_cancelled, persist_collected, raise_if_cancelled from .errors import BrowseCancelledError, NoFeedsError from .extract_feeds_state import extract_feeds_list, merge_feed_dicts from .human import _sleep_interruptible as sleep_interruptible_sec, sleep_random from .search_filters import apply_search_filters, filter_option_to_labels, wait_search_results_ready from .types import Feed, FilterOption from .urls import make_search_url logger = logging.getLogger(__name__) def _feeds_from_collected(collected: dict[str, dict]) -> list[Feed]: return [Feed.from_dict(f) for f in collected.values()] def search_feeds( page: Page, keyword: str, filter_option: FilterOption | None = None, *, scroll_speed: str = "normal", on_progress=None, ) -> tuple[list[Feed], bool, dict]: """搜索并采集笔记:先等列表 → 应用筛选 → 按拟人计划滚动采集。""" keyword = (keyword or "").strip() if not keyword: raise ValueError("搜索关键词不能为空") session = BrowsePlanSession("search") def progress(msg: str) -> None: logger.info(msg) if on_progress: on_progress(msg) search_url = make_search_url(keyword) collected: dict[str, dict] = {} meta = { "browse_scroll_target": session.scroll_target, "browse_click_target": session.click_target, "browse_targets": session.targets.to_dict(), "collect_elapsed_sec": 0.0, "plan_completed": False, } def absorb(label: str) -> int: items = extract_feeds_list(page) added = merge_feed_dicts(collected, items) if added: logger.info("%s: 新增 %d 条,累计 %d 条", label, added, len(collected)) persist_collected(collected) raise_if_cancelled() return added try: progress(session.start_message()) progress(f"打开搜索页:{keyword}") page.navigate(search_url) page.wait_for_load() page.wait_dom_stable() wait_search_results_ready(page, timeout=22.0) absorb("搜索页初次加载") labels = filter_option_to_labels(filter_option) if labels: progress(f"应用筛选:{'、'.join(labels)}") result = apply_search_filters(page, labels) clicked = result.get("clicked") or [] missed = result.get("missed") or [] if clicked: progress(f"已选筛选:{'、'.join(clicked)}") if missed: progress(f"部分筛选未生效:{'、'.join(missed)}") if not result.get("ok"): progress("筛选面板可能未打开,将使用当前列表继续采集") if is_cancelled(): raise BrowseCancelledError() wait_search_results_ready(page, timeout=18.0) absorb("应用筛选后") sleep_random(600, 1000) list_url = page.evaluate("window.location.href") or search_url progress("打开搜索页完成") progress("滚动加载更多结果…") def on_scroll_collect() -> None: absorb("滚动中") human_browse_feed_list( page, list_url, scroll_speed=scroll_speed, click_notes=session.click_target > 0, browse_mode="search", on_progress=on_progress, after_scroll=on_scroll_collect, run_targets=session.targets, ) if not is_cancelled(): parse_sec = session.targets.parse_sec if parse_sec > 0: progress( f"模拟浏览:提取并解析列表数据(约 {parse_sec:.1f} 秒)…" ) sleep_interruptible_sec(parse_sec) progress("模拟浏览:提取并解析列表数据…") absorb("滚动结束后") progress("模拟浏览:解析完成") if not collected: raise NoFeedsError() meta["plan_completed"] = True meta["collect_elapsed_sec"] = round(session.elapsed_sec(), 1) progress(session.summary(len(collected), plan_completed=True)) return _feeds_from_collected(collected), False, meta except BrowseCancelledError: meta["collect_elapsed_sec"] = round(session.elapsed_sec(), 1) if collected: progress( session.summary( len(collected), plan_completed=False, user_cancelled=True, ) ) return _feeds_from_collected(collected), True, meta raise