"""真实网站关键词搜索 RPA 示例(async Playwright)。""" from __future__ import annotations import asyncio import logging import os import random import re import time from dataclasses import dataclass, field from typing import Any, Callable, Dict, List, Optional, Tuple from service.browser_session import close_browser_context, get_start_url, start_browser_session from service.human_verification import ( HumanVerificationWaitResult, wait_human_verification_if_present, wait_save_login_countdown_if_present, ) from util.constants import ( DEFAULT_MAX_ITEMS, DEFAULT_MAX_SCROLLS, DEFAULT_NO_NEW_ROUNDS_LIMIT, HUMAN_WAIT_TIMEOUT, LOG_LOGGER_NAME, RESULT_CONTAINER_SELECTOR, RESULT_END_TEXT, RESULT_ITEM_SELECTOR, SCROLL_CONTAINER_SELECTORS, SEARCH_BUTTON_SELECTOR, SEARCH_INPUT_SELECTORS, ) from util.logging import mask_text, safe_log_value logger = logging.getLogger(LOG_LOGGER_NAME) @dataclass class ScrapeError: code: str message: str browser_started: bool = False stage: Optional[str] = None @dataclass class ScrapeRunResult: success: bool items: List[Dict[str, Any]] = field(default_factory=list) stop_reason: Optional[str] = None error: Optional[ScrapeError] = None StepCallback = Callable[[str], None] # 示例登录态 selector — 复制到新 skill 后必须用目标站点 F12/DOM 实测替换,禁止凭经验猜测或跨平台照抄 LOGGED_IN_SELECTORS = ( "a[href*='/user/self']", "[data-user-avatar]", ".user-avatar", ) LOGGED_OUT_SELECTOR = "button:has-text('登录')" def format_stop_reason_for_user(stop_reason: str | None, collected_count: int) -> str: """将内部 stop_reason 枚举翻译为用户可理解的中文说明。""" if stop_reason == "max_items": return ( f"已达到本次默认采集规模({collected_count} 条)。" "如需更多,可以继续说「继续采集更多」或「尽量多采集」。" ) if stop_reason == "end_marker": return "页面提示没有更多结果,本次采集已完成。" if stop_reason == "no_new_rounds": return "连续多次加载没有发现新结果,本次采集已完成。" if stop_reason == "max_scrolls": return ( "已达到本次页面加载保护范围,本次采集已完成。" "如需更深度采集,可以说明「尽量多采集」。" ) if stop_reason == "human_verification_timeout": return "人工验证未在规定时间内完成,任务已停止。" return "本次采集已完成。" def _mask_account_ref(account: Dict[str, Any]) -> str: for key in ("login_id", "phone", "mobile"): raw = str(account.get(key) or "").strip() if raw: return mask_text(raw) aid = account.get("id") or account.get("account_id") if aid is not None: return f"id:{aid}" return "unknown" def _step_cb(cb: Optional[StepCallback], text: str) -> None: if cb: cb(text) async def _random_delay() -> None: lo = int(os.getenv("RPA_STEP_DELAY_MIN_MS") or "900") hi = int(os.getenv("RPA_STEP_DELAY_MAX_MS") or "2600") await asyncio.sleep(random.uniform(lo / 1000.0, hi / 1000.0)) async def _scroll_wait() -> None: await asyncio.sleep(random.uniform(1.2, 3.5)) def _headless() -> bool: v = (os.getenv("OPENCLAW_BROWSER_HEADLESS") or "0").strip().lower() return v in ("1", "true", "yes", "on") def _no_new_limit() -> int: try: return int(os.getenv("RPA_NO_NEW_ROUNDS_LIMIT") or str(DEFAULT_NO_NEW_ROUNDS_LIMIT)) except ValueError: return DEFAULT_NO_NEW_ROUNDS_LIMIT async def _visible(locator) -> bool: try: return await locator.count() > 0 and await locator.is_visible(timeout=500) except Exception: return False async def _is_logged_out(page) -> bool: try: btn = page.locator(LOGGED_OUT_SELECTOR).first return await _visible(btn) except Exception: return False async def _is_logged_in(page) -> bool: if await _is_logged_out(page): return False for sel in LOGGED_IN_SELECTORS: try: loc = page.locator(sel).first if await _visible(loc): return True except Exception: continue return False async def _open_login_panel_if_needed(page) -> None: if not await _is_logged_out(page): return try: btn = page.locator(LOGGED_OUT_SELECTOR).first if await _visible(btn): await btn.click() await asyncio.sleep(random.uniform(1.0, 3.0)) except Exception: pass async def _ensure_logged_in(page, *, wait_sec: int) -> None: if await _is_logged_in(page): return await _open_login_panel_if_needed(page) if await _is_logged_in(page): return print(f"[登录] 请在浏览器中完成登录,最多等待 {wait_sec} 秒...") deadline = time.monotonic() + wait_sec while time.monotonic() < deadline: if await _is_logged_in(page): print("[登录] 检测到登录成功") return await asyncio.sleep(2.0) raise RuntimeError( f"ERROR:LOGIN_TIMEOUT 浏览器已打开,但未完成登录。" f"请重新运行任务并在浏览器打开后及时完成登录。" ) async def _find_search_input(page) -> Tuple[Optional[Any], str]: for sel in SEARCH_INPUT_SELECTORS: try: loc = page.locator(sel).first if await _visible(loc): return loc, sel except Exception: continue candidates: List[Tuple[int, Any, str]] = [] try: inputs = await page.locator("input").all() except Exception: inputs = [] for inp in inputs: try: if not await inp.is_visible(): continue box = await inp.bounding_box() if not box or box.get("width", 0) < 120: continue placeholder = await inp.get_attribute("placeholder") or "" score = box.get("width", 0) if "搜索" in placeholder: score += 500 if box.get("y", 9999) < 200: score += 200 candidates.append((score, inp, f"input placeholder={placeholder!r}")) except Exception: continue if candidates: candidates.sort(key=lambda x: x[0], reverse=True) _, inp, sel = candidates[0] return inp, sel try: ph = page.get_by_placeholder(re.compile("搜索")) if await ph.count() > 0 and await ph.first.is_visible(): return ph.first, "placeholder=搜索" except Exception: pass return None, "" async def _find_search_button(page): try: btn = page.get_by_role("button", name="搜索") if await btn.count() > 0 and await btn.first.is_visible(): return btn.first except Exception: pass try: btn = page.locator(SEARCH_BUTTON_SELECTOR).first if await btn.count() > 0 and await btn.is_visible(): return btn except Exception: pass return None async def _wait_search_results(page, timeout_sec: float = 45.0) -> bool: deadline = time.monotonic() + timeout_sec while time.monotonic() < deadline: try: url = page.url or "" if "/search" in url or "search" in url.lower(): if await page.locator(RESULT_CONTAINER_SELECTOR).count() > 0: return True except Exception: pass await asyncio.sleep(0.8) return False async def _has_end_marker(page) -> bool: try: container = page.locator(RESULT_CONTAINER_SELECTOR).first if await container.count() > 0: text = await container.inner_text(timeout=2000) if RESULT_END_TEXT in text or "暂时没有更多了" in text: return True except Exception: pass try: if await page.locator(f"text={RESULT_END_TEXT}").count() > 0: return True except Exception: pass return False async def _mouse_wheel_scroll(page) -> Tuple[str, int]: wheel_delta = random.randint(650, 1200) for sel in SCROLL_CONTAINER_SELECTORS: loc = page.locator(sel) try: if await loc.count() > 0 and await loc.first.is_visible(): await loc.first.hover() await _random_delay() await page.mouse.wheel(0, wheel_delta) return sel, wheel_delta except Exception: continue await page.mouse.wheel(0, wheel_delta) return "page", wheel_delta async def _parse_item_locator(item) -> Dict[str, Any]: item_id = await item.get_attribute("data-item-id") or await item.get_attribute("id") or "" title = "" try: title = (await item.inner_text(timeout=1000)).strip()[:200] except Exception: pass return { "item_id": item_id or title[:32] or str(id(item)), "title": title, } async def _collect_visible_items(page, seen_ids: set[str]) -> List[Dict[str, Any]]: new_items: List[Dict[str, Any]] = [] try: items = await page.locator(RESULT_ITEM_SELECTOR).all() except Exception: items = [] for item in items: try: parsed = await _parse_item_locator(item) item_id = parsed.get("item_id") if not item_id or item_id in seen_ids: continue seen_ids.add(str(item_id)) new_items.append(parsed) except Exception: continue return new_items async def _count_visible_items(page) -> int: try: return await page.locator(RESULT_ITEM_SELECTOR).count() except Exception: return 0 def _fail_result(error: ScrapeError, *, collected: int = 0) -> ScrapeRunResult: logger.warning( "rpa_failed code=%s stage=%s message=%s collected=%s", error.code, error.stage, error.message, collected, ) return ScrapeRunResult(success=False, error=error) def _fail_on_verification_timeout( verification_timeout: HumanVerificationWaitResult, *, collected: int = 0, ) -> ScrapeRunResult: return _fail_result( ScrapeError( code=verification_timeout.code, message=verification_timeout.message, browser_started=True, stage=verification_timeout.stage or "unknown", ), collected=collected, ) async def run_keyword_search_async( account: Dict[str, Any], keyword: str, *, max_items: int = DEFAULT_MAX_ITEMS, max_scrolls: int = DEFAULT_MAX_SCROLLS, data_dir: str = "", batch_id: str = "", step_callback: Optional[StepCallback] = None, ) -> ScrapeRunResult: profile_dir = account.get("profile_dir") or "" account_ref = _mask_account_ref(account) wait_sec = int(os.getenv("HUMAN_WAIT_TIMEOUT") or str(HUMAN_WAIT_TIMEOUT)) no_new_limit = _no_new_limit() logger.info( "rpa_start keyword=%s max_items=%s max_scrolls=%s wait_sec=%s batch_id=%s account_ref=%s", safe_log_value(keyword), max_items, max_scrolls, wait_sec, batch_id, account_ref, ) if not profile_dir: err = ScrapeError( "ACCOUNT_NOT_FOUND", "账号缺少 profile_dir。", browser_started=False, stage="account_prepare", ) return _fail_result(err) pw = None context = None page = None browser_started = False collected_count = 0 try: _step_cb(step_callback, "启动关键词搜索采集任务") try: pw, context, page = await start_browser_session(profile_dir, headless=_headless()) except RuntimeError as exc: msg = str(exc) err = ScrapeError( "MISSING_BROWSER" if "MISSING_BROWSER" in msg else "UNKNOWN_ERROR", msg.replace("ERROR:MISSING_BROWSER ", "").replace("ERROR:", ""), browser_started="未能打开目标首页" in msg, stage="browser_start" if "未能打开目标首页" in msg else "account_prepare", ) return _fail_result(err) browser_started = True logger.info( "browser_ready start_url=%s current_url=%s profile_dir_exists=%s", get_start_url(), page.url, os.path.isdir(profile_dir), ) _step_cb(step_callback, "打开目标站点首页") await _random_delay() verification_timeout = await wait_human_verification_if_present( page, wait_sec=wait_sec, stage="browser_start" ) if verification_timeout: return _fail_on_verification_timeout(verification_timeout) _step_cb(step_callback, "检查账号登录状态") logger.info("login_check_start current_url=%s", page.url) try: await _ensure_logged_in(page, wait_sec=wait_sec) except RuntimeError as exc: msg = str(exc) code = "LOGIN_TIMEOUT" if "LOGIN_TIMEOUT" in msg else "REQUIRE_LOGIN" err = ScrapeError( code, msg.replace("ERROR:LOGIN_TIMEOUT ", "").replace("ERROR:", ""), browser_started=True, stage="login_check", ) return _fail_result(err) logger.info("login_check_done current_url=%s", page.url) await wait_save_login_countdown_if_present(page) verification_timeout = await wait_human_verification_if_present( page, wait_sec=wait_sec, stage="login_check" ) if verification_timeout: return _fail_on_verification_timeout(verification_timeout) _step_cb(step_callback, "定位搜索框") search_input, input_selector = await _find_search_input(page) if search_input is None: err = ScrapeError( "SEARCH_INPUT_NOT_FOUND", "未找到搜索输入框。", browser_started=True, stage="search", ) return _fail_result(err) logger.info("search_input_found selector=%s", input_selector) _step_cb(step_callback, f"输入关键词:{keyword}") await search_input.click() await _random_delay() await page.keyboard.press("Control+A") await page.keyboard.type(keyword, delay=random.randint(90, 240)) await _random_delay() _step_cb(step_callback, "点击搜索") search_btn = await _find_search_button(page) search_method = "button" if search_btn is not None: await search_btn.click() else: search_method = "enter" await page.keyboard.press("Enter") logger.info( "search_submitted keyword=%s method=%s current_url=%s", safe_log_value(keyword), search_method, page.url, ) await _random_delay() verification_timeout = await wait_human_verification_if_present( page, wait_sec=wait_sec, stage="search_verification" ) if verification_timeout: return _fail_on_verification_timeout(verification_timeout) _step_cb(step_callback, "等待搜索结果") if not await _wait_search_results(page): verification_timeout = await wait_human_verification_if_present( page, wait_sec=wait_sec, stage="search_results" ) if verification_timeout: return _fail_on_verification_timeout(verification_timeout) if not await _wait_search_results(page, timeout_sec=10.0): err = ScrapeError( "RESULT_CONTAINER_NOT_FOUND", "搜索结果未出现,且未检测到需要人工处理的验证码,请检查关键词或页面状态。", browser_started=True, stage="search", ) return _fail_result(err) container_count = await page.locator(RESULT_CONTAINER_SELECTOR).count() logger.info( "search_results_ready current_url=%s container_count=%s", page.url, container_count, ) try: await page.locator(RESULT_CONTAINER_SELECTOR).first.wait_for(state="visible", timeout=15_000) except Exception: err = ScrapeError( "RESULT_CONTAINER_NOT_FOUND", "搜索结果容器未出现。", browser_started=True, stage="search", ) return _fail_result(err) seen_ids: set[str] = set() collected: List[Dict[str, Any]] = [] no_new_rounds = 0 stop_reason = "max_scrolls" rounds_executed = 0 for scroll_round in range(max_scrolls + 1): rounds_executed = scroll_round _step_cb(step_callback, "读取当前结果列表") visible_count = await _count_visible_items(page) batch = await _collect_visible_items(page, seen_ids) new_count = len(batch) if batch: collected.extend(batch) no_new_rounds = 0 else: no_new_rounds += 1 collected_count = len(collected) end_marker = await _has_end_marker(page) logger.info( "collect_round round=%s max_scrolls=%s visible_count=%s new_count=%s " "collected=%s no_new_rounds=%s no_new_limit=%s end_marker=%s current_url=%s", scroll_round, max_scrolls, visible_count, new_count, collected_count, no_new_rounds, no_new_limit, end_marker, page.url, ) if len(collected) >= max_items: collected = collected[:max_items] stop_reason = "max_items" break if end_marker: stop_reason = "end_marker" _step_cb(step_callback, "发现结果到底或达到采集数量") break if no_new_rounds >= no_new_limit: stop_reason = "no_new_rounds" logger.info( "collect_no_new_stop round=%s no_new_rounds=%s no_new_limit=%s collected=%s", scroll_round, no_new_rounds, no_new_limit, collected_count, ) break if scroll_round >= max_scrolls: break _step_cb(step_callback, "滚动加载更多结果") scroll_target, wheel_delta = await _mouse_wheel_scroll(page) logger.info( "scroll round=%s target=%s wheel_delta=%s", scroll_round, scroll_target, wheel_delta, ) await _scroll_wait() verification_timeout = await wait_human_verification_if_present( page, wait_sec=wait_sec, stage="collect" ) if verification_timeout: return _fail_on_verification_timeout( verification_timeout, collected=collected_count, ) logger.info( "collect_stop reason=%s collected=%s rounds=%s no_new_rounds=%s", stop_reason, collected_count, rounds_executed, no_new_rounds, ) if not collected: err = ScrapeError( "NO_RESULTS", "未采集到任何结果。", browser_started=True, stage="collect", ) return _fail_result(err, collected=0) _step_cb(step_callback, "采集完成") logger.info( "rpa_success collected=%s stop_reason=%s", len(collected[:max_items]), stop_reason, ) return ScrapeRunResult(success=True, items=collected[:max_items], stop_reason=stop_reason) except Exception as exc: err = ScrapeError( "UNKNOWN_ERROR", str(exc), browser_started=browser_started, stage="rpa", ) return _fail_result(err, collected=collected_count) finally: await close_browser_context(pw, context)