"""首页 / 搜索列表页的人类化浏览(滚动、停留、点开笔记弹窗养号)。 养号点开笔记:随机点击封面打开弹窗(note_detail_modal),不走路由。 按 feed_id 拉取详情:见 feed_detail.py(路由)。""" from __future__ import annotations import logging import sys import time from collections.abc import Callable from typing import TYPE_CHECKING, Literal from .browse_plan_session import BrowseRunTargets from .collect_buffer import is_cancelled from .note_detail_modal import BrowseScene, browse_note_modal_session from .human import ( BUTTON_CLICK_INTERVAL, DEFAULT_MAX_ATTEMPTS, HUMAN_DELAY, HOVER_TIME, LARGE_SCROLL_TRIGGER, MIN_SCROLL_DELTA, POST_SCROLL, STAGNANT_CHECK_THRESHOLD, STAGNANT_LIMIT, get_scroll_interval, human_scroll_page, navigation_delay, sleep_random, ) from .human import _sleep_interruptible as sleep_interruptible_sec if TYPE_CHECKING: from .cdp import Page logger = logging.getLogger(__name__) ProgressFn = Callable[[str], None] | None BrowseMode = Literal["list", "search"] def _click_label(mode: BrowseMode) -> str: return "点开详情" if mode == "search" else "点开笔记" def _emit_progress(on_progress: ProgressFn, msg: str) -> None: logger.info(msg) if on_progress: on_progress(msg) print(f"@@PROGRESS@@{msg}", file=sys.stderr, flush=True) def _browse_scene(mode: BrowseMode) -> BrowseScene: return "search" if mode == "search" else "home" def _run_final_sprint( page: Page, scroll_speed: str, sprint_sec: float, on_progress: ProgressFn, ) -> None: """在配置的秒数内收尾滚动。""" _emit_progress(on_progress, "模拟浏览:收尾冲刺…") budget = max(0.5, float(sprint_sec)) deadline = time.monotonic() + budget while time.monotonic() < deadline: if is_cancelled(): return human_scroll_page(page, scroll_speed, True, 1) remaining = deadline - time.monotonic() if remaining <= 0: break sleep_random(200, 400) time.sleep(min(get_scroll_interval(scroll_speed), remaining)) sleep_random(*HUMAN_DELAY) _emit_progress(on_progress, "模拟浏览:收尾冲刺完成") def _open_one_note( page: Page, list_url: str, scroll_speed: str, *, mode: BrowseMode, run_targets: BrowseRunTargets | None, on_progress: ProgressFn, index: int, total: int, ) -> bool: """养号:随机点击笔记封面打开弹窗,拟人后关闭(不走路由、不拉详情)。""" label = _click_label(mode) _emit_progress(on_progress, f"模拟浏览:{label} ({index}/{total})…") if run_targets: stay = run_targets.detail_stay_sec detail_scrolls = run_targets.detail_scroll_target else: stay = 15.0 detail_scrolls = 4 ok = browse_note_modal_session( page, list_url, scroll_speed, scene=_browse_scene(mode), stay_sec=stay, scroll_count=detail_scrolls, ) if ok: navigation_delay() else: logger.debug("%s: 弹窗拟人未完成", label) return ok def _fulfill_click_quota( page: Page, list_url: str, scroll_speed: str, *, mode: BrowseMode, run_targets: BrowseRunTargets | None, on_progress: ProgressFn, clicks_done: int, click_target: int, ) -> int: """滚动结束后补足未完成的点开次数(搜索页链接常较晚出现)。""" label = _click_label(mode) done = clicks_done max_tries = max(click_target - done, 0) * 4 + 2 tries = 0 while done < click_target and tries < max_tries: if is_cancelled(): break tries += 1 if _open_one_note( page, list_url, scroll_speed, mode=mode, run_targets=run_targets, on_progress=on_progress, index=done + 1, total=click_target, ): done += 1 if done >= click_target: _emit_progress( on_progress, f"模拟浏览:{label}完成({done}/{click_target})", ) else: sleep_random(400, 800) return done def human_browse_feed_list( page: Page, list_url: str, *, scroll_speed: str = "normal", click_notes: bool = True, browse_mode: BrowseMode = "list", on_progress: ProgressFn = None, max_attempts: int | None = None, stagnant_limit: int | None = None, after_scroll: Callable[[], None] | None = None, run_targets: BrowseRunTargets | None = None, ) -> None: """在 Feed 列表页模拟真人:按拟人计划滑动、点开、收尾。""" scroll_target = run_targets.scroll_target if run_targets else 0 click_target = run_targets.click_target if run_targets else 0 if run_targets and run_targets.stable_sec > 0: _emit_progress( on_progress, f"模拟浏览:等待页面稳定(约 {run_targets.stable_sec:.1f} 秒)…", ) sleep_interruptible_sec(run_targets.stable_sec) else: _emit_progress(on_progress, "模拟浏览:等待页面稳定…") navigation_delay() sleep_random(300, 800) _emit_progress(on_progress, "模拟浏览:页面稳定完成") stagnant = 0 clicks_done = 0 scrolls_done = 0 large_mode = False push_count = 0 last_scroll_top = page.get_scroll_top() click_label = _click_label(browse_mode) use_plan = run_targets is not None and scroll_target > 0 effective_click = click_target if use_plan else 0 if click_notes and not use_plan: effective_click = 3 stop_stagnant = stagnant_limit if stagnant_limit is not None else STAGNANT_LIMIT if use_plan: cap = scroll_target + max(effective_click, 1) * 8 + 16 else: cap = max_attempts if max_attempts is not None else DEFAULT_MAX_ATTEMPTS max_attempts = min(cap, stop_stagnant + 25) scroll_finished = False for attempt in range(max_attempts): if is_cancelled(): _emit_progress(on_progress, "模拟浏览:已停止") return if ( click_notes and effective_click > 0 and clicks_done < effective_click and scrolls_done < scroll_target and attempt > 0 and attempt % max(1, BUTTON_CLICK_INTERVAL) == 0 ): if _open_one_note( page, list_url, scroll_speed, mode=browse_mode, run_targets=run_targets, on_progress=on_progress, index=clicks_done + 1, total=effective_click, ): clicks_done += 1 stagnant = 0 push_count = 0 large_mode = False last_scroll_top = page.get_scroll_top() if clicks_done >= effective_click: _emit_progress( on_progress, f"模拟浏览:{click_label}完成({clicks_done}/{effective_click})", ) if stagnant >= STAGNANT_CHECK_THRESHOLD: large_mode = True if push_count >= LARGE_SCROLL_TRIGGER: large_mode = True if use_plan and scrolls_done >= scroll_target: scroll_finished = True break scroll_label = ( f"模拟浏览:滚动 ({scrolls_done + 1}/{scroll_target})…" if use_plan else f"模拟浏览:滚动 ({attempt + 1})…" ) _emit_progress(on_progress, scroll_label) actual_delta, current_top = human_scroll_page( page, scroll_speed, large_mode, push_count if large_mode and push_count > 0 else 1, ) scrolls_done += 1 sleep_random(*POST_SCROLL) time.sleep(get_scroll_interval(scroll_speed)) if after_scroll: after_scroll() if actual_delta < MIN_SCROLL_DELTA or current_top == last_scroll_top: stagnant += 1 else: stagnant = 0 push_count = 0 large_mode = False last_scroll_top = current_top push_count += 1 if stagnant >= stop_stagnant: _emit_progress(on_progress, "模拟浏览:已滑到底部") scroll_finished = True break if attempt % 5 == 4: sleep_random(*HOVER_TIME) if use_plan and scrolls_done > 0: _emit_progress( on_progress, f"模拟浏览:滑动完成({scrolls_done}/{scroll_target})", ) elif not scroll_finished and scrolls_done > 0: _emit_progress( on_progress, f"模拟浏览:滑动完成({scrolls_done} 次)", ) if click_notes and effective_click > 0 and clicks_done < effective_click: clicks_done = _fulfill_click_quota( page, list_url, scroll_speed, mode=browse_mode, run_targets=run_targets, on_progress=on_progress, clicks_done=clicks_done, click_target=effective_click, ) sprint_sec = run_targets.sprint_sec if run_targets else 5.0 _run_final_sprint(page, scroll_speed, sprint_sec, on_progress)