"""养号场景:首页推荐 / 搜索列表 — 点击笔记打开弹窗拟人(不走路由、不拉详情)。 - 场景 1、2:list-feeds / search-feeds 浏览采集时点开笔记 → 本模块 - 场景 3:按 feed_id 查详情 → 使用 feed_detail.navigate_to_feed_detail / get_feed_detail(路由) """ from __future__ import annotations import json import logging import random import re import time from typing import TYPE_CHECKING, Literal from .collect_buffer import is_cancelled from .human import ( HOVER_TIME, INACCESSIBLE_KEYWORDS, POST_SCROLL, get_scroll_interval, get_scroll_ratio, sleep_random, ) from .human import _sleep_interruptible as sleep_interruptible_sec if TYPE_CHECKING: from .cdp import Page logger = logging.getLogger(__name__) BrowseScene = Literal["home", "search"] _INACCESSIBLE_EXTRA = ( "页面不见了", "你访问的页面不见了", "error_code=300031", ) # 仅计算封面点击坐标,不在页面内 dispatchEvent(避免触发 Vue Router 整页跳转) _PICK_RANDOM_NOTE_CLICK_TARGET_JS = """ (() => { const cards = Array.from(document.querySelectorAll( '#exploreFeeds section.note-item, section.note-item, .feeds-container section.note-item' )); const visible = cards.filter((el) => { const r = el.getBoundingClientRect(); return r.width > 40 && r.height > 40 && r.top < window.innerHeight && r.bottom > 0; }); if (!visible.length) { const anchors = Array.from(document.querySelectorAll('a[href*="/explore/"]')) .filter(a => /\\/explore\\/[a-f0-9]{16,24}/i.test(a.getAttribute('href') || a.href || '')); if (!anchors.length) return { ok: false, reason: 'no-anchor' }; const a = anchors[Math.floor(Math.random() * anchors.length)]; const card = a.closest('section.note-item') || a.closest('section') || a.parentElement; if (!card) return { ok: false, reason: 'no-card' }; card.scrollIntoView({ block: 'center', inline: 'nearest' }); const cover = a.querySelector('a.cover img, a.cover, img'); const hit = cover || a; const r = hit.getBoundingClientRect(); return { ok: true, x: Math.round(r.left + r.width * (0.38 + Math.random() * 0.24)), y: Math.round(r.top + r.height * (0.38 + Math.random() * 0.24)), via: 'anchor-fallback', }; } const card = visible[Math.floor(Math.random() * visible.length)]; card.scrollIntoView({ block: 'center', inline: 'nearest' }); const cover = card.querySelector('a.cover img, a.cover, img'); const hit = cover || card.querySelector('a.cover') || card; const r = hit.getBoundingClientRect(); if (r.width < 30 || r.height < 30) return { ok: false, reason: 'too-small' }; return { ok: true, x: Math.round(r.left + r.width * (0.38 + Math.random() * 0.24)), y: Math.round(r.top + r.height * (0.38 + Math.random() * 0.24)), via: 'section-note-item', }; })() """ _INJECT_BLOCK_NAV_JS = """ (ms) => { const until = Date.now() + ms; const cleanup = () => { for (const type of ['click', 'pointerdown', 'pointerup', 'mousedown', 'mouseup', 'auxclick']) { document.removeEventListener(type, window.__xhsBrowseBlockNav, true); } window.__xhsBrowseBlockNav = null; window.__xhsBrowseBlockNavUntil = 0; }; if (window.__xhsBrowseBlockNav) cleanup(); window.__xhsBrowseBlockNavUntil = until; window.__xhsBrowseBlockNav = function(e) { if (Date.now() > window.__xhsBrowseBlockNavUntil) return; const link = e.target && e.target.closest ? e.target.closest('a[href*="/explore/"]') : null; if (!link) return; e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); return false; }; for (const type of ['click', 'pointerdown', 'pointerup', 'mousedown', 'mouseup', 'auxclick']) { document.addEventListener(type, window.__xhsBrowseBlockNav, true); } setTimeout(cleanup, ms); return until; } """ _MODAL_VISIBLE_JS = """ (() => { const sels = [ '.note-detail-mask', '.note-container', '[class*="note-detail"]', '[class*="NoteDetail"]', '.note-detail', '#noteContainer', '.engage-bar', ]; for (const sel of sels) { for (const el of document.querySelectorAll(sel)) { const r = el.getBoundingClientRect(); const st = getComputedStyle(el); if (r.width > 100 && r.height > 100 && st.display !== 'none' && st.visibility !== 'hidden' && parseFloat(st.opacity || '1') > 0.05) { return true; } } } return false; })() """ _IS_LIST_PAGE_JS = """ (() => { const p = location.pathname || ''; const h = location.href || ''; if (h.includes('search_result')) return true; if (p === '/explore' || p === '/explore/') return true; if (p.startsWith('/explore') && !/\\/explore\\/[a-f0-9]{16,24}/i.test(p)) return true; return false; })() """ _BLOCKED_KWS_JSON = json.dumps( list(INACCESSIBLE_KEYWORDS) + list(_INACCESSIBLE_EXTRA), ensure_ascii=False, ) _BLOCKED_PAGE_JS = f""" (() => {{ const text = ((document.body && document.body.innerText) || '') + ' ' + (location.href || ''); const kws = {_BLOCKED_KWS_JSON}; for (const kw of kws) {{ if (kw && text.includes(kw)) return kw; }} if ((location.href || '').includes('/404')) return '404'; return ''; }})() """ _FULL_NOTE_PAGE_RE = re.compile(r"/explore/[a-f0-9]{16,24}", re.I) _SCROLL_MODAL_FN = """ (delta) => { const roots = [ document.querySelector('.note-scroller'), document.querySelector('.note-detail'), document.querySelector('[class*="note-detail"]'), document.querySelector('.interaction-container'), document.querySelector('.note-container'), document.querySelector('[class*="NoteDetail"]'), ].filter(Boolean); let el = roots.find(r => r.scrollHeight > r.clientHeight + 40); if (!el) el = document.scrollingElement || document.documentElement; el.scrollBy({ top: delta, behavior: 'instant' }); } """ _PICK_MASK_CLICK_TARGET_JS = """ (() => { const mask = document.querySelector('.note-detail-mask') || document.querySelector('[class*="note-detail-mask"]') || document.querySelector('[class*="Mask"]'); const panel = document.querySelector('.note-container') || document.querySelector('[class*="note-detail"]') || document.querySelector('#noteContainer'); const vw = window.innerWidth; const vh = window.innerHeight; const outsidePanel = (x, y, pr) => { if (!pr || pr.width < 10) return true; const pad = 12; return x < pr.left - pad || x > pr.right + pad || y < pr.top - pad || y > pr.bottom + pad; }; const pr = panel ? panel.getBoundingClientRect() : null; const candidates = []; if (mask) { const mr = mask.getBoundingClientRect(); candidates.push( { x: mr.left + 24, y: mr.top + mr.height / 2 }, { x: mr.right - 24, y: mr.top + mr.height / 2 }, { x: mr.left + mr.width / 2, y: mr.top + 18 }, { x: mr.left + mr.width / 2, y: mr.bottom - 18 }, ); } candidates.push( { x: 28, y: vh / 2 }, { x: vw - 28, y: vh / 2 }, { x: vw / 2, y: 24 }, ); for (const c of candidates) { if (outsidePanel(c.x, c.y, pr)) return c; } return { x: 32, y: vh / 2 }; })() """ _CLOSE_MODAL_JS = """ (() => { const sels = [ '.close-circle', '.close-box', '.close-wrapper', '[class*="close-icon"]', '[class*="CloseIcon"]', 'svg.close', ]; for (const sel of sels) { for (const el of document.querySelectorAll(sel)) { const r = el.getBoundingClientRect(); if (r.width >= 10 && r.height >= 10) { const btn = el.closest('button,[role="button"],div') || el; btn.click(); return true; } } } return false; })() """ def _mouse_click_only(page: Page, x: float, y: float) -> None: """仅 CDP 鼠标点击,不触发 element.click()(避免点到 走路由)。""" jx = x + random.uniform(-4, 4) jy = y + random.uniform(-4, 4) page.mouse_move(jx, jy) time.sleep(random.uniform(0.08, 0.16)) page.mouse_click(jx, jy) def _inject_block_link_navigation(page: Page, ms: int = 2800) -> None: page.evaluate(f"({ _INJECT_BLOCK_NAV_JS })({int(ms)})") def _is_on_list_page(page: Page) -> bool: return page.evaluate(_IS_LIST_PAGE_JS) is True def _navigated_to_full_note_page(page: Page) -> bool: href = page.evaluate("window.location.href") or "" if "/404" in href: return True if _FULL_NOTE_PAGE_RE.search(href) and page.evaluate(_MODAL_VISIBLE_JS) is not True: return not _is_on_list_page(page) return False def _recover_from_wrong_navigation(page: Page, list_url: str) -> None: """误走路由页时优先 back,避免 navigate 再次触发风控。""" for _ in range(2): if _is_on_list_page(page) and page.evaluate(_MODAL_VISIBLE_JS) is not True: return try: page.evaluate("window.history.back()") page.wait_for_load() sleep_random(500, 900) except Exception as exc: logger.debug("history.back: %s", exc) break if not _is_on_list_page(page) and list_url: logger.debug("仍不在列表页,最后尝试 navigate: %s", list_url) page.navigate(list_url) page.wait_for_load() page.wait_dom_stable() def _wait_modal(page: Page, timeout: float = 12.0) -> bool: deadline = time.monotonic() + timeout while time.monotonic() < deadline: if is_cancelled(): return False if page.evaluate(_MODAL_VISIBLE_JS) is True: return True time.sleep(0.3) return False def _wait_modal_closed(page: Page, timeout: float = 6.0) -> bool: deadline = time.monotonic() + timeout while time.monotonic() < deadline: if is_cancelled(): return True if page.evaluate(_MODAL_VISIBLE_JS) is not True: return True time.sleep(0.25) return False def _click_mask_to_close(page: Page) -> None: target = page.evaluate(_PICK_MASK_CLICK_TARGET_JS) if isinstance(target, dict): _mouse_click_only(page, float(target.get("x", 32)), float(target.get("y", 200))) else: _mouse_click_only(page, 32, page.get_viewport_height() / 2) def _blocked_reason(page: Page) -> str: reason = page.evaluate(_BLOCKED_PAGE_JS) return str(reason).strip() if reason else "" def _click_random_note_on_page(page: Page) -> bool: """在列表 DOM 上用 CDP 真实鼠标点击封面;长期拦截 /explore/ 链接默认路由。""" list_href = page.evaluate("window.location.href") or "" _inject_block_link_navigation(page, 8000) result = page.evaluate(_PICK_RANDOM_NOTE_CLICK_TARGET_JS) if not isinstance(result, dict) or not result.get("ok"): logger.debug("未找到可点击笔记卡片: %s", result) return False x = float(result.get("x", 0)) y = float(result.get("y", 0)) if x <= 0 or y <= 0: return False logger.debug( "CDP 点击封面 (%.0f, %.0f) via=%s", x, y, result.get("via", ""), ) _mouse_click_only(page, x, y) sleep_random(600, 1200) if _wait_modal(page, timeout=12.0): return True if _navigated_to_full_note_page(page): logger.info("CDP 点击后误进笔记路由页,history.back 恢复列表") _recover_from_wrong_navigation(page, list_href) sleep_random(400, 800) if _wait_modal(page, timeout=5.0): return True return False # 未出现弹窗也未走路由:再试一次(仍不 dispatch) _inject_block_link_navigation(page, 5000) result2 = page.evaluate(_PICK_RANDOM_NOTE_CLICK_TARGET_JS) if isinstance(result2, dict) and result2.get("ok"): x2 = float(result2.get("x", 0)) y2 = float(result2.get("y", 0)) if x2 > 0 and y2 > 0: _mouse_click_only(page, x2, y2) sleep_random(600, 1200) if _wait_modal(page, timeout=10.0): return True if _navigated_to_full_note_page(page): _recover_from_wrong_navigation(page, list_href) return False def open_random_note_modal(page: Page, *, scene: BrowseScene) -> bool: """养号:随机点击列表笔记打开弹窗,绝不 page.navigate 到笔记 URL。""" list_href = page.evaluate("window.location.href") or "" if not _click_random_note_on_page(page): logger.debug("[%s] 页面点击未打开弹窗", scene) if _navigated_to_full_note_page(page): _recover_from_wrong_navigation(page, list_href) return False if _navigated_to_full_note_page(page): logger.info("[%s] 点击后误进路由页,已回退列表", scene) _recover_from_wrong_navigation(page, list_href) return False blocked = _blocked_reason(page) if blocked: logger.info("[%s] 弹窗不可浏览: %s", scene, blocked) close_browse_note_modal(page, list_href) return False return True def dwell_in_browse_note_modal( page: Page, scroll_speed: str, *, stay_sec: float, scroll_count: int, ) -> None: """弹窗内拟人停留:详情区滚动 + 补足停留秒数(不解析数据)。""" page.wait_dom_stable() budget = max(10.0, float(stay_sec)) scrolls = max(1, int(scroll_count)) started = time.monotonic() viewport = max(400, page.get_viewport_height()) ratio = get_scroll_ratio(scroll_speed) for i in range(scrolls): if is_cancelled(): return delta = max(260, int(viewport * (ratio + random.random() * 0.12))) page.evaluate(f"({_SCROLL_MODAL_FN})({delta})") sleep_random(*POST_SCROLL) time.sleep(get_scroll_interval(scroll_speed)) if i % 2 == 1: sleep_random(*HOVER_TIME) remaining = budget - (time.monotonic() - started) if remaining > 0: sleep_interruptible_sec(remaining) def close_browse_note_modal(page: Page, list_url: str) -> None: """关闭养号弹窗:点击弹窗外空白,回到列表可继续滚动。""" if page.evaluate(_MODAL_VISIBLE_JS) is not True: if _navigated_to_full_note_page(page): _recover_from_wrong_navigation(page, list_url) return _click_mask_to_close(page) sleep_random(450, 850) if _wait_modal_closed(page, timeout=5.0): sleep_random(300, 500) if _navigated_to_full_note_page(page): _recover_from_wrong_navigation(page, list_url) return logger.debug("点击遮罩未关闭,尝试关闭按钮 / Esc") page.evaluate(_CLOSE_MODAL_JS) sleep_random(350, 650) if not _wait_modal_closed(page, timeout=3.0): try: page.press_key("Escape") except Exception: pass sleep_random(400, 700) _wait_modal_closed(page, timeout=3.0) if _navigated_to_full_note_page(page): _recover_from_wrong_navigation(page, list_url) elif not _is_on_list_page(page) and list_url: _recover_from_wrong_navigation(page, list_url) else: page.wait_dom_stable() def browse_note_modal_session( page: Page, list_url: str, scroll_speed: str, *, scene: BrowseScene, stay_sec: float, scroll_count: int, ) -> bool: """完整一轮:点开弹窗 → 拟人停留 → 点空白关闭。成功返回 True。""" if not open_random_note_modal(page, scene=scene): return False try: dwell_in_browse_note_modal( page, scroll_speed, stay_sec=stay_sec, scroll_count=scroll_count, ) return True finally: close_browse_note_modal(page, list_url)