Download apps/express_query/services/browser_query_service.py from jun2024/fc: direct link, hf CLI and curl.
- Browser
- Download file 51.6 kB
-
https://huggingface.co/spaces/jun2024/fc/resolve/main/apps/express_query/services/browser_query_service.py
- Command line
-
hf download hf://spaces/jun2024/fc/apps/express_query/services/browser_query_service.py
-
curl -L -o browser_query_service.py https://huggingface.co/spaces/jun2024/fc/resolve/main/apps/express_query/services/browser_query_service.py
51.6 kB
| from __future__ import annotations | |
| import asyncio | |
| import logging | |
| import subprocess | |
| import tempfile | |
| from pathlib import Path | |
| from typing import TYPE_CHECKING, Any, Final | |
| from apps.express_query.models import ExpressCarrierType | |
| if TYPE_CHECKING: | |
| from playwright.async_api import Browser, BrowserContext, Page | |
| logger = logging.getLogger("apps.express_query") | |
| SF_HOME_URL: Final[str] = "https://www.sf-express.com/" | |
| SF_QUERY_URL: Final[str] = "https://www.sf-express.com/chn/sc/waybill/list" | |
| EMS_HOME_URL: Final[str] = "https://www.11183.com.cn/" | |
| EMS_QUERY_URL: Final[str] = "https://www.11183.com.cn/?to=%2Fquery_express_delivery" | |
| # EMS 协议相关 XPath(与 test_ems_full_flow.py 保持一致) | |
| _EMS_LOGIN_AGREE_CHECKBOX_XPATH: Final[str] = ( | |
| "//*[@id='app']/div[1]/div/header/div[1]/div/div/div[3]/div/div[2]/div/div[2]/div[3]/div[1]/label/span/span" | |
| ) | |
| _EMS_AGREEMENT_MODAL_XPATH: Final[str] = "//*[@id='app']/div[2]" | |
| _EMS_AGREEMENT_LAST_CLAUSE_XPATH: Final[str] = "//*[@id='app']/div[2]/div/div[2]/div/div[1]/div[1]/ul/li[5]/div/p" | |
| _EMS_AGREEMENT_ACCEPT_BUTTON_XPATH: Final[str] = "//*[@id='app']/div[2]/div/div[2]/div/div[1]/div[2]/div[3]/button[2]" | |
| # Global state | |
| _browser_context: BrowserContext | None = None | |
| _CDP_PORT: Final[int] = 9222 | |
| _CDP_URL: Final[str] = f"http://127.0.0.1:{_CDP_PORT}" | |
| _user_data_dir: Path = Path(tempfile.mkdtemp(prefix="express_chrome_")) | |
| _chrome_process: subprocess.Popen | None = None | |
| class ExpressBrowserQueryService: | |
| async def close_browser() -> None: | |
| global _browser_context, _chrome_process | |
| if _browser_context is not None: | |
| try: | |
| await _browser_context.close() | |
| except Exception: | |
| pass | |
| _browser_context = None | |
| if _chrome_process is not None: | |
| try: | |
| _chrome_process.terminate() | |
| _chrome_process.wait(timeout=5) | |
| except Exception: | |
| try: | |
| _chrome_process.kill() | |
| except Exception: | |
| pass | |
| _chrome_process = None | |
| logger.info("Browser closed") | |
| async def _ensure_browser() -> BrowserContext: | |
| """ | |
| Get available browser context (auto-launch Chrome + CDP connect). | |
| Strategy: | |
| 1. Reuse existing context in current process | |
| 2. Try CDP connect to existing Chrome | |
| 3. Auto-launch Chrome with debug port, then CDP connect | |
| SF and EMS share the same Chrome window. | |
| """ | |
| from playwright.async_api import async_playwright | |
| global _browser_context, _chrome_process | |
| # Case 1: existing context is alive -> reuse | |
| if _browser_context is not None and _browser_context.pages: | |
| try: | |
| page = await _browser_context.new_page() | |
| await page.evaluate("1+1") | |
| await page.close() | |
| return _browser_context | |
| except Exception: | |
| _browser_context = None | |
| pw = await async_playwright().start() | |
| # Case 2: try CDP connect to existing Chrome | |
| browser = await ExpressBrowserQueryService._try_cdp_connect(pw) | |
| if browser is not None: | |
| ctx_list = list(browser.contexts) | |
| _browser_context = ( | |
| ctx_list[0] | |
| if ctx_list | |
| else await browser.new_context( | |
| viewport={"width": 1440, "height": 900}, | |
| ) | |
| ) | |
| logger.info("Connected to existing Chrome via CDP") | |
| return _browser_context | |
| # Case 3: auto-launch Chrome then connect | |
| ExpressBrowserQueryService._launch_chrome() | |
| await asyncio.sleep(2) | |
| browser = await ExpressBrowserQueryService._try_cdp_connect(pw, retries=10, delay=0.5) | |
| if browser is not None: | |
| ctx_list2 = list(browser.contexts) | |
| _browser_context = ( | |
| ctx_list2[0] | |
| if ctx_list2 | |
| else await browser.new_context( | |
| viewport={"width": 1440, "height": 900}, | |
| ) | |
| ) | |
| logger.info("Connected to auto-launched Chrome") | |
| return _browser_context | |
| raise RuntimeError( | |
| f"Cannot connect via CDP ({_CDP_URL}) after launching Chrome. Check if Chrome is installed correctly." | |
| ) | |
| async def _try_cdp_connect( | |
| pw: Any, | |
| retries: int = 1, | |
| delay: float = 0, | |
| ) -> Browser | None: | |
| """Try connecting to Chrome via CDP. Returns Browser or None.""" | |
| for attempt in range(retries): | |
| try: | |
| browser: Browser = await pw.chromium.connect_over_cdp(_CDP_URL) | |
| return browser | |
| except Exception: | |
| if attempt < retries - 1: | |
| await asyncio.sleep(delay) | |
| return None | |
| def _launch_chrome() -> None: | |
| """Auto-launch Chrome with remote debugging port enabled.""" | |
| import platform | |
| global _chrome_process | |
| system_name = platform.system().lower() | |
| if system_name == "darwin": | |
| chrome_path = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" | |
| elif system_name == "linux": | |
| chrome_path = "google-chrome" | |
| else: | |
| chrome_path = r"C:\Program Files\Google\Chrome\Application\chrome.exe" | |
| _user_data_dir.mkdir(parents=True, exist_ok=True) | |
| cmd: list[str] = [ | |
| chrome_path, | |
| f"--remote-debugging-port={_CDP_PORT}", | |
| f"--user-data-dir={_user_data_dir}", | |
| "--no-first-run", | |
| "--no-default-browser-check", | |
| "--disable-blink-features=AutomationControlled", | |
| ] | |
| try: | |
| _chrome_process = subprocess.Popen( | |
| cmd, | |
| stdout=subprocess.DEVNULL, | |
| stderr=subprocess.DEVNULL, | |
| ) | |
| logger.info( | |
| "Auto-launched Chrome (PID=%d), debug port=%d", | |
| _chrome_process.pid, | |
| _CDP_PORT, | |
| ) | |
| except FileNotFoundError: | |
| raise RuntimeError(f"Chrome not found at: {chrome_path}") from None | |
| except OSError as exc: | |
| raise RuntimeError(f"Failed to launch Chrome: {exc}") from exc | |
| async def query_and_save_pdf(self, carrier_type: str, tracking_number: str, output_pdf: Path) -> str: | |
| output_pdf.parent.mkdir(parents=True, exist_ok=True) | |
| context = await self._ensure_browser() | |
| page = await context.new_page() | |
| try: | |
| if carrier_type == ExpressCarrierType.SF: | |
| await self._query_sf(page, tracking_number) | |
| elif carrier_type == ExpressCarrierType.EMS: | |
| await self._query_ems(page, tracking_number) | |
| else: | |
| raise ValueError(f"Unsupported carrier: {carrier_type}") | |
| final_url = str(page.url) | |
| # EMS 详情页加载较慢,等待完全加载 | |
| if carrier_type == ExpressCarrierType.EMS: | |
| try: | |
| await page.wait_for_load_state("networkidle", timeout=30000) | |
| logger.info("EMS page fully loaded") | |
| except Exception: | |
| logger.warning("EMS networkidle timeout, proceeding with PDF anyway") | |
| await asyncio.sleep(2) | |
| # 注入日期时间 + URL 页眉 | |
| from datetime import datetime | |
| now_str: str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") | |
| watermark_id: str = "__express_watermark__" | |
| await page.evaluate( | |
| """([text, url, id]) => { | |
| const div = document.createElement('div'); | |
| div.id = id; | |
| div.style.cssText = | |
| 'position:fixed;top:0;left:0;right:0;z-index:2147483647;' + | |
| 'background:rgba(0,0,0,0.75);color:#fff;padding:6px 16px;' + | |
| 'font-size:12px;font-family:-apple-system,sans-serif;' + | |
| 'display:flex;justify-content:space-between;pointer-events:none;'; | |
| div.innerHTML = '<span>' + text + '</span><span style="opacity:0.7;max-width:60%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + url + '</span>'; | |
| document.body.appendChild(div); | |
| }""", | |
| [now_str, final_url, watermark_id], | |
| ) | |
| await asyncio.sleep(0.3) | |
| await page.pdf( | |
| path=str(output_pdf), | |
| format="A4", | |
| print_background=True, | |
| margin={"top": "40px", "bottom": "20px", "left": "20px", "right": "20px"}, | |
| ) | |
| # 移除页眉 | |
| await page.evaluate( | |
| "(id) => { const el = document.getElementById(id); if (el) el.remove(); }", | |
| watermark_id, | |
| ) | |
| return final_url | |
| finally: | |
| try: | |
| await page.close() | |
| logger.info("Closed query result tab") | |
| except Exception: | |
| pass | |
| async def _query_sf(self, page: Page, tracking_number: str) -> None: | |
| await page.goto(SF_HOME_URL, wait_until="networkidle") | |
| await asyncio.sleep(2) | |
| await self._dismiss_sf_overlays(page) | |
| await self._click_first( | |
| page, | |
| [ | |
| "button:has-text('登录')", | |
| "a:has-text('登录')", | |
| ".login-btn", | |
| ], | |
| ) | |
| logger.info("SF page opened, please login in the browser") | |
| await self._wait_for_login( | |
| page, | |
| login_selectors=["button:has-text('登录')", "a:has-text('登录')"], | |
| user_selectors=["[class*='user']", "[class*='avatar']", "text=退出登录"], | |
| timeout_seconds=300, | |
| ) | |
| await page.goto(SF_QUERY_URL, wait_until="networkidle") | |
| await asyncio.sleep(3) | |
| await self._dismiss_sf_overlays(page) | |
| await self._fill_first( | |
| page, | |
| [ | |
| "input[placeholder*='查询']", | |
| "input[type='text']", | |
| ], | |
| tracking_number, | |
| ) | |
| if not await self._click_first(page, ["button:has-text('查')", "button.search-icon"]): | |
| await page.keyboard.press("Enter") | |
| await asyncio.sleep(3) | |
| await self._open_sf_waybill_detail(page, tracking_number) | |
| async def _dismiss_sf_overlays(self, page: Page) -> None: | |
| close_selectors = [ | |
| ".guide-close", | |
| ".driver-close-btn", | |
| "[class*='guide'] [class*='close']", | |
| "[class*='tour'] [class*='close']", | |
| "[class*='mask'] [class*='close']", | |
| "button[class*='skip']", | |
| "button:has-text('下一步')", | |
| "button:has-text('完成')", | |
| "button:has-text('跳过')", | |
| "button:has-text('知道了')", | |
| "button:has-text('我知道了')", | |
| "button:has-text('关闭')", | |
| "button:has-text('暂不')", | |
| "button:has-text('同意')", | |
| "span:has-text('下一步')", | |
| "span:has-text('完成')", | |
| "span:has-text('同意')", | |
| ".el-dialog__close", | |
| ".el-message-box__close", | |
| ] | |
| logger.info(" Dismissing SF overlays...") | |
| for _ in range(10): | |
| closed_any = False | |
| try: | |
| await page.keyboard.press("Escape") | |
| await asyncio.sleep(0.3) | |
| except Exception: | |
| pass | |
| try: | |
| next_button = page.get_by_role("button", name="下一步") | |
| if await next_button.count() > 0 and await next_button.first.is_visible(): | |
| await next_button.first.click(force=True, timeout=1500) | |
| logger.info(" Clicked guide button") | |
| await asyncio.sleep(0.8) | |
| closed_any = True | |
| continue | |
| except Exception: | |
| pass | |
| try: | |
| agree_button = page.get_by_role("button", name="同意") | |
| if await agree_button.count() > 0 and await agree_button.first.is_visible(): | |
| await agree_button.first.click(force=True, timeout=1500) | |
| logger.info(" Clicked agree button") | |
| await asyncio.sleep(0.8) | |
| closed_any = True | |
| except Exception: | |
| pass | |
| for selector in close_selectors: | |
| if await self._click_first(page, [selector]): | |
| logger.info(" Clicked overlay control: %s", selector) | |
| await asyncio.sleep(0.5) | |
| closed_any = True | |
| try: | |
| overlay_boxes = await page.evaluate("""() => Array.from(document.querySelectorAll('body *')) | |
| .map((element) => { | |
| const htmlElement = element; | |
| const style = window.getComputedStyle(htmlElement); | |
| const rect = htmlElement.getBoundingClientRect(); | |
| return { | |
| position: style.position, | |
| zIndex: Number(style.zIndex || '0'), | |
| width: rect.width, | |
| height: rect.height, | |
| left: rect.left, | |
| top: rect.top, | |
| right: rect.right, | |
| }; | |
| }) | |
| .filter((item) => | |
| item.position === 'fixed' && | |
| item.zIndex >= 10 && | |
| item.width >= 180 && | |
| item.height >= 80 && | |
| item.top >= 0 | |
| ) | |
| .slice(0, 6)""") | |
| for box in overlay_boxes: | |
| try: | |
| await page.mouse.click(box["right"] - 18, box["top"] + 18) | |
| logger.info(" Clicked overlay corner") | |
| await asyncio.sleep(0.5) | |
| closed_any = True | |
| except Exception: | |
| pass | |
| except Exception: | |
| pass | |
| try: | |
| await page.evaluate("""() => { | |
| document.querySelectorAll('.mask').forEach((element) => { | |
| element.classList.remove('mask'); | |
| element.style.pointerEvents = 'auto'; | |
| }); | |
| document.querySelectorAll('[class*="mask"]').forEach((element) => { | |
| element.style.pointerEvents = 'none'; | |
| }); | |
| document.querySelectorAll('input, button, a').forEach((element) => { | |
| element.style.pointerEvents = 'auto'; | |
| }); | |
| }""") | |
| except Exception: | |
| pass | |
| if not closed_any: | |
| break | |
| async def _open_sf_waybill_detail(self, page: Page, tracking_number: str) -> None: | |
| logger.info("Opening SF waybill detail: %s", tracking_number) | |
| detail_button_selectors = [ | |
| "button:has-text('展开详情')", | |
| "[role='button']:has-text('展开详情')", | |
| "span:has-text('展开详情')", | |
| "text=展开详情", | |
| "button:has-text('查看详情')", | |
| ] | |
| verification_selectors = [ | |
| "text=收起详情", | |
| "text=物流轨迹", | |
| "text=签收时间", | |
| "text=签收详情", | |
| "text=收方", | |
| "text=寄方", | |
| ] | |
| deadline = asyncio.get_running_loop().time() + 30 | |
| while asyncio.get_running_loop().time() < deadline: | |
| await self._dismiss_sf_overlays(page) | |
| detail_opened = False | |
| for selector in detail_button_selectors: | |
| if await self._click_first(page, [selector]): | |
| logger.info(" Clicked expand detail: %s", selector) | |
| await asyncio.sleep(2) | |
| detail_opened = True | |
| break | |
| if not detail_opened: | |
| try: | |
| clicked = await page.evaluate("""() => { | |
| const elements = Array.from(document.querySelectorAll('body *')); | |
| for (const element of elements) { | |
| const text = (element.innerText || '').trim(); | |
| if (!text || !text.includes('\u5c55\u5f00\u8be6\u60c5')) { | |
| continue; | |
| } | |
| let current = element; | |
| while (current) { | |
| if ( | |
| current.tagName === 'A' || | |
| current.tagName === 'BUTTON' || | |
| current.getAttribute('role') === 'button' || | |
| current.onclick || | |
| current.className?.toString().includes('item') || | |
| current.className?.toString().includes('card') | |
| ) { | |
| current.click(); | |
| return true; | |
| } | |
| current = current.parentElement; | |
| } | |
| } | |
| return false; | |
| }""") | |
| if clicked: | |
| logger.info(" Clicked expand detail via DOM search") | |
| await asyncio.sleep(2) | |
| detail_opened = True | |
| except Exception: | |
| pass | |
| if detail_opened: | |
| expand_selectors = [ | |
| "button:has-text('展开全部轨迹')", | |
| "button:has-text('查看全部')", | |
| "button:has-text('查看更多')", | |
| "button:has-text('展开')", | |
| ] | |
| for selector in expand_selectors: | |
| await self._click_first(page, [selector]) | |
| await asyncio.sleep(1) | |
| for selector in verification_selectors: | |
| try: | |
| if await page.locator(selector).count() > 0: | |
| logger.info(" Detail confirmed: %s", selector) | |
| return | |
| except Exception: | |
| pass | |
| await asyncio.sleep(1) | |
| raise RuntimeError("SF detail expansion failed: %s" % tracking_number) | |
| async def _is_ems_dialog_visible(self, page: Page) -> bool: | |
| """检测 EMS 登录弹窗是否正在显示。""" | |
| # 方法1: el-dialog.scan 容器可见 | |
| try: | |
| dialog = page.locator(".el-dialog.scan") | |
| if await dialog.count() > 0 and await dialog.first.is_visible(): | |
| return True | |
| except Exception: | |
| pass | |
| # 方法2: 可见的"扫码登录"文字 | |
| try: | |
| qr = page.locator("text=扫码登录") | |
| if await qr.count() > 0 and await qr.first.is_visible(): | |
| return True | |
| except Exception: | |
| pass | |
| # 方法3: 协议文字 | |
| try: | |
| agree = page.locator("text=请阅读并同意服务协议") | |
| if await agree.count() > 0 and await agree.first.is_visible(): | |
| return True | |
| except Exception: | |
| pass | |
| return False | |
| async def _ems_click_login_button(self, page: Page) -> bool: | |
| """点击 EMS 页面的「登录/注册」按钮。返回是否点击成功。""" | |
| selectors: list[str] = [ | |
| "text=登录/注册", | |
| "a:text('登录/注册')", | |
| "div:text('登录/注册')", | |
| "span:text('登录/注册')", | |
| ] | |
| for selector in selectors: | |
| loc = page.locator(selector) | |
| try: | |
| count = await loc.count() | |
| except Exception: | |
| continue | |
| for i in range(min(count, 5)): | |
| target = loc.nth(i) | |
| try: | |
| if await target.is_visible(): | |
| await target.click(force=True, timeout=3000) | |
| return True | |
| except Exception: | |
| continue | |
| # JS 兜底 | |
| try: | |
| clicked = await page.evaluate("""() => { | |
| const els = Array.from(document.querySelectorAll('a, div, span, button')); | |
| for (const el of els) { | |
| const t = (el.innerText || '').trim(); | |
| if ((t === '登录/注册' || t === '登录') && el.getBoundingClientRect().width > 0) { | |
| el.click(); return true; | |
| } | |
| } | |
| return false; | |
| }""") | |
| return bool(clicked) | |
| except Exception: | |
| return False | |
| async def _query_ems(self, page: Page, tracking_number: str) -> None: | |
| """ | |
| EMS 查询主流程: | |
| 1. 用 ?to= 进入(未登录时自动弹登录框) | |
| 2. 检测登录状态:未登录→处理协议+等待扫码;已登录→跳过 | |
| 3. 导航到干净的 query_express_delivery 页面(不含 ?to=) | |
| 4. 输入单号搜索 + 打开详情 | |
| """ | |
| from urllib.parse import parse_qs, urlparse | |
| context = page.context | |
| _clean_query_url: str = "https://www.11183.com.cn/query_express_delivery" | |
| # ===== 阶段1: 进入页面(?to= 触发登录弹窗)===== | |
| await page.goto(EMS_QUERY_URL, wait_until="domcontentloaded") | |
| try: | |
| await page.wait_for_load_state("networkidle", timeout=8000) | |
| except Exception: | |
| logger.info("EMS networkidle timeout, continuing") | |
| await asyncio.sleep(2) | |
| # ===== 阶段2: 检测并处理登录状态 ===== | |
| dialog_showing = await self._is_ems_dialog_visible(page) | |
| if dialog_showing: | |
| logger.info("EMS login dialog auto-showed (via ?to=), no button click needed") | |
| else: | |
| logger.info("Login dialog not visible, clicking 登录/注册...") | |
| clicked = await self._ems_click_login_button(page) | |
| if clicked: | |
| await asyncio.sleep(1.5) | |
| if not await self._is_ems_dialog_visible(page): | |
| logger.info("Dialog still not showing, re-clicking...") | |
| await self._ems_click_login_button(page) | |
| await asyncio.sleep(1) | |
| else: | |
| logger.warning("Failed to click login button") | |
| # ===== 阶段3: 判断是否已登录,否则处理协议 + 等待扫码 ===== | |
| if not await self._is_ems_dialog_visible(page): | |
| body_text = "" | |
| try: | |
| body_text = (await page.locator("body").text_content()) or "" | |
| except Exception: | |
| pass | |
| _logged_in_keywords = ("退出", "我的EMS", "个人中心", "我的快递") | |
| if any(kw in body_text for kw in _logged_in_keywords): | |
| logger.info("EMS user already logged in, skipping login flow") | |
| else: | |
| logger.info("Starting EMS agreement handling + login wait...") | |
| await self._ems_handle_agreement_and_wait(context, page, timeout_seconds=300) | |
| else: | |
| logger.info("Starting EMS agreement handling + login wait...") | |
| await self._ems_handle_agreement_and_wait(context, page, timeout_seconds=300) | |
| # ===== 阶段4: 导航到干净查询页(不含 ?to=)再操作 ===== | |
| current_url_lower = page.url.lower() | |
| if ( | |
| "personal_center" in current_url_lower | |
| or "query_express_delivery" not in current_url_lower | |
| or "?to=" in page.url | |
| ): | |
| logger.info("Navigating to clean query page: %s", _clean_query_url) | |
| await page.goto(_clean_query_url, wait_until="domcontentloaded") | |
| try: | |
| await page.wait_for_load_state("networkidle", timeout=8000) | |
| except Exception: | |
| pass | |
| await asyncio.sleep(2) | |
| await self._dismiss_ems_overlays(page) | |
| await self._fill_first( | |
| page, | |
| [ | |
| "input[placeholder*='邮件号']", | |
| "input[placeholder*='搜索']", | |
| "input[type='search']", | |
| "input[type='text']", | |
| ], | |
| tracking_number, | |
| ) | |
| if not await self._click_first( | |
| page, | |
| [ | |
| "button:has-text('搜索')", | |
| "button:has-text('查询')", | |
| "button:has-text('查件')", | |
| ], | |
| ): | |
| await page.keyboard.press("Enter") | |
| await asyncio.sleep(3) | |
| await self._open_ems_waybill_detail(page, tracking_number) | |
| async def _dismiss_ems_overlays(self, page: Page) -> None: | |
| close_selectors = [ | |
| "button:has-text('知道了')", | |
| "button:has-text('我知道了')", | |
| "button:has-text('同意')", | |
| "button:has-text('关闭')", | |
| "button:has-text('跳过')", | |
| "button:has-text('稍后')", | |
| "button:has-text('确定')", | |
| "span:has-text('关闭')", | |
| "span:has-text('知道了')", | |
| ".close-btn", | |
| ".icon-close", | |
| "[class*='dialog'] [role='button']", | |
| "[class*='popup'] [role='button']", | |
| "[class*='mask'] [class*='close']", | |
| ] | |
| logger.info(" Dismissing EMS popups...") | |
| for _ in range(8): | |
| closed_any = False | |
| try: | |
| await page.keyboard.press("Escape") | |
| await asyncio.sleep(0.2) | |
| except Exception: | |
| pass | |
| for selector in close_selectors: | |
| if await self._click_first(page, [selector]): | |
| logger.info(" Clicked popup control: %s", selector) | |
| closed_any = True | |
| try: | |
| clicked = await page.evaluate("""() => { | |
| const elements = Array.from(document.querySelectorAll('body *')); | |
| for (const element of elements) { | |
| const htmlElement = element; | |
| const style = window.getComputedStyle(htmlElement); | |
| const rect = htmlElement.getBoundingClientRect(); | |
| // 提高阈值:只关闭真正的大弹窗,避免误点卡片/内容区 | |
| if ( | |
| style.position === 'fixed' && | |
| Number(style.zIndex || '0') >= 100 && | |
| rect.width >= 300 && | |
| rect.height >= 200 | |
| ) { | |
| const x = rect.right - 18; | |
| const y = rect.top + 18; | |
| const target = document.elementFromPoint(x, y); | |
| if (target instanceof HTMLElement) { | |
| target.click(); | |
| return true; | |
| } | |
| } | |
| } | |
| return false; | |
| }""") | |
| if clicked: | |
| logger.info(" Clicked popup corner") | |
| await asyncio.sleep(0.5) | |
| closed_any = True | |
| except Exception: | |
| pass | |
| try: | |
| await page.evaluate("""() => { | |
| document.querySelectorAll('[class*="mask"], [class*="overlay"], [class*="modal-bg"]').forEach((element) => { | |
| element.style.pointerEvents = 'none'; | |
| }); | |
| document.querySelectorAll('button, a, input, textarea, [role="button"]').forEach((element) => { | |
| element.style.pointerEvents = 'auto'; | |
| }); | |
| }""") | |
| except Exception: | |
| pass | |
| if not closed_any: | |
| break | |
| async def _open_ems_waybill_detail(self, page: Page, tracking_number: str) -> None: | |
| logger.info("Opening EMS mail detail: %s", tracking_number) | |
| detail_button_selectors = [ | |
| "button:has-text('查看详情')", | |
| "button:has-text('详情')", | |
| "button:has-text('物流详情')", | |
| "button:has-text('邮件详情')", | |
| "button:has-text('收寄详情')", | |
| "a:has-text('查看详情')", | |
| "a:has-text('详情')", | |
| ] | |
| detail_state_selectors = [ | |
| "text=物流轨迹", | |
| "text=收寄详情", | |
| "text=收件人", | |
| "text=寄件人", | |
| "text=签收", | |
| "text=妥投", | |
| ] | |
| deadline = asyncio.get_running_loop().time() + 45 | |
| detail_action_done = False | |
| dismiss_count: int = 0 | |
| while asyncio.get_running_loop().time() < deadline: | |
| # 只在前 3 次循环尝试关闭弹窗,防止死循环误关内容 | |
| if dismiss_count < 3: | |
| await self._dismiss_ems_overlays(page) | |
| dismiss_count += 1 | |
| try: | |
| await self._click_first(page, [f"text={tracking_number}"]) | |
| except Exception: | |
| pass | |
| try: | |
| clicked_detail_from_dom = await page.evaluate( | |
| """(trackingNumber) => { | |
| const normalizedTarget = String(trackingNumber).replace(/\\s+/g, ''); | |
| const elements = Array.from(document.querySelectorAll('body *')); | |
| for (const element of elements) { | |
| const text = String(element.innerText || element.textContent || '').replace(/\\s+/g, ''); | |
| if (!text || !text.includes(normalizedTarget)) { | |
| continue; | |
| } | |
| let container = element; | |
| for (let depth = 0; depth < 6 && container; depth += 1) { | |
| const detailNode = Array.from(container.querySelectorAll('button,a,span,div')).find((node) => { | |
| const nodeText = String(node.innerText || node.textContent || '').trim(); | |
| return /\u67e5\u770b\u8be6\u60c5|\u8be6\u60c5|\u7269\u6d41\u8be6\u60c5|\u90ae\u4ef6\u8be6\u60c5|\u6536\u5bc4\u8be6\u60c5/.test(nodeText); | |
| }); | |
| if (detailNode instanceof HTMLElement) { | |
| detailNode.click(); | |
| return true; | |
| } | |
| container = container.parentElement; | |
| } | |
| } | |
| return false; | |
| }""", | |
| tracking_number, | |
| ) | |
| if clicked_detail_from_dom: | |
| detail_action_done = True | |
| logger.info(" Triggered detail click via DOM") | |
| await asyncio.sleep(2) | |
| except Exception: | |
| pass | |
| if not detail_action_done: | |
| for selector in detail_button_selectors: | |
| if await self._click_first(page, [selector]): | |
| detail_action_done = True | |
| logger.info(" Clicked detail button: %s", selector) | |
| await asyncio.sleep(2) | |
| break | |
| if detail_action_done: | |
| expand_selectors = [ | |
| "button:has-text('展开详情')", | |
| "button:has-text('查看详情')", | |
| "button:has-text('物流详情')", | |
| "button:has-text('邮件详情')", | |
| "button:has-text('收寄详情')", | |
| "button:has-text('查看更多')", | |
| "button:has-text('查看全部')", | |
| "button:has-text('全部轨迹')", | |
| "button:has-text('展开')", | |
| ] | |
| for selector in expand_selectors: | |
| await self._click_first(page, [selector]) | |
| await asyncio.sleep(2) | |
| for selector in detail_state_selectors: | |
| try: | |
| if await page.locator(selector).count() > 0: | |
| logger.info(" EMS detail confirmed: %s", selector) | |
| return | |
| except Exception: | |
| pass | |
| await asyncio.sleep(1) | |
| if not detail_action_done: | |
| raise RuntimeError("EMS detail failed: no click triggered for %s" % tracking_number) | |
| raise RuntimeError("EMS detail failed: clicked but no detail block appeared for %s" % tracking_number) | |
| async def _wait_for_login( | |
| self, | |
| page: Page, | |
| *, | |
| login_selectors: list[str], | |
| user_selectors: list[str], | |
| timeout_seconds: int, | |
| ) -> None: | |
| deadline = asyncio.get_running_loop().time() + timeout_seconds | |
| while asyncio.get_running_loop().time() < deadline: | |
| login_visible = await self._has_any_visible(page, login_selectors) | |
| user_visible = await self._has_any_visible(page, user_selectors) | |
| if user_visible or not login_visible: | |
| return | |
| await asyncio.sleep(2) | |
| # ==================== EMS 协议处理(来自 test_ems_full_flow.py) ==================== | |
| def _is_ems_login_window(page: Page, body_text: str) -> bool: | |
| """判断页面是否为 EMS 登录/扫码窗口。""" | |
| url_lower = page.url.lower() | |
| url_keywords = ("login", "passport", "auth", "oauth", "qrcode", "wx") | |
| if any(kw in url_lower for kw in url_keywords): | |
| return True | |
| body_keywords = ( | |
| "扫码登录", | |
| "请使用微信扫码", | |
| "微信扫码", | |
| "登录", | |
| "手机号登录", | |
| "验证码登录", | |
| ) | |
| return any(kw in body_text for kw in body_keywords) | |
| async def _ems_ensure_agreement_checked(self, page: Page) -> bool: | |
| """勾选"登录即代表您已同意"协议(仅精确 XPath,避免误点)。""" | |
| exact_checkbox = page.locator(f"xpath={_EMS_LOGIN_AGREE_CHECKBOX_XPATH}") | |
| if await self._click_locator_if_visible(exact_checkbox, "agreement checkbox (exact XPath)"): | |
| return True | |
| # 只用精确文本匹配,不用模糊 class 选择器 | |
| fallback_selectors = [ | |
| "label:has-text('登录即代表您已同意')", | |
| "span:has-text('登录即代表您已同意')", | |
| ] | |
| for selector in fallback_selectors: | |
| if await self._click_first(page, [selector]): | |
| logger.info(" Agreement checked via text: %s", selector) | |
| return True | |
| # DOM 搜索也限定更精确的关键词 | |
| try: | |
| clicked = await page.evaluate("""() => { | |
| const exactPattern = /\\u767b\\u5f55\\u5373\\u4ee3\\u8868\\u60a8\\u5df2\\u540c\\u610f/; | |
| const elements = Array.from(document.querySelectorAll('label, span')); | |
| for (const element of elements) { | |
| const text = String(element.innerText || element.textContent || '').trim(); | |
| if (!text || !exactPattern.test(text)) continue; | |
| const relatedInput = | |
| element.querySelector('input[type="checkbox"]') | |
| || element.closest('label')?.querySelector('input[type="checkbox"]') | |
| || element.parentElement?.querySelector('input[type="checkbox"]'); | |
| if (relatedInput instanceof HTMLInputElement) { | |
| if (!relatedInput.checked) relatedInput.click(); | |
| return true; | |
| } | |
| element.click(); | |
| return true; | |
| } | |
| return false; | |
| }""") | |
| if clicked: | |
| logger.info(" Agreement checked via DOM") | |
| await asyncio.sleep(0.6) | |
| return True | |
| except Exception: | |
| pass | |
| return False | |
| async def _ems_scroll_agreement_and_accept(self, agreement_page: Page) -> bool: | |
| """尝试点击协议确认按钮,若不可点击则提示用户手动操作。""" | |
| accept_btn = agreement_page.locator(f"xpath={_EMS_AGREEMENT_ACCEPT_BUTTON_XPATH}") | |
| if await self._click_locator_if_visible(accept_btn, "agreement accept button"): | |
| return True | |
| logger.info(" Please scroll to bottom and click '我已阅读并同意' manually") | |
| return True | |
| async def _ems_open_last_agreement_and_accept(self, context: BrowserContext, page: Page) -> bool: | |
| """点击最后一个协议链接 → 滚动到底部 → 点击确认按钮。""" | |
| pages_before = len(context.pages) | |
| # 只用精确 XPath 触发,不用模糊选择器避免误点 | |
| trigger_clicked = await self._click_locator_if_visible( | |
| page.locator(f"xpath={_EMS_LOGIN_AGREE_CHECKBOX_XPATH}"), | |
| "agreement trigger (exact XPath)", | |
| ) | |
| if not trigger_clicked: | |
| logger.info(" Agreement checkbox not found yet (login may still be loading)") | |
| return False | |
| # 判断是否打开了新页面(弹窗页) | |
| agreement_page: Page = page | |
| if len(context.pages) > pages_before: | |
| agreement_page = context.pages[-1] | |
| try: | |
| await agreement_page.wait_for_load_state("domcontentloaded", timeout=5000) | |
| except Exception: | |
| pass | |
| # 等待协议弹层出现 | |
| modal_loc = agreement_page.locator(f"xpath={_EMS_AGREEMENT_MODAL_XPATH}") | |
| try: | |
| if await modal_loc.count() > 0: | |
| await modal_loc.first.wait_for(timeout=4000) | |
| logger.info(" Agreement modal appeared") | |
| except Exception: | |
| pass | |
| # 点击最后一个条款滚动到底部 | |
| last_clause = agreement_page.locator(f"xpath={_EMS_AGREEMENT_LAST_CLAUSE_XPATH}") | |
| if await self._click_locator_if_visible(last_clause, "last clause"): | |
| await asyncio.sleep(0.6) | |
| accepted = await self._ems_scroll_agreement_and_accept(agreement_page) | |
| # 如果是新开的页面,关闭它 | |
| if agreement_page is not page: | |
| try: | |
| await agreement_page.close() | |
| logger.info(" Closed agreement page") | |
| except Exception: | |
| pass | |
| return accepted | |
| async def _ems_handle_agreement_and_wait( | |
| self, context: BrowserContext, page: Page, timeout_seconds: int = 300 | |
| ) -> None: | |
| """ | |
| EMS 完整登录流程: | |
| - 检测弹窗可见性 | |
| - 自动处理协议(勾选 checkbox → 同意 → 确认) | |
| - 等待用户扫码 | |
| - 检测登录成功 | |
| 基于 ems_explore.py 验证通过的逻辑。 | |
| """ | |
| logger.info("EMS agreement + login wait (timeout=%ds)", timeout_seconds) | |
| deadline = asyncio.get_running_loop().time() + timeout_seconds | |
| for attempt in range(20): | |
| # ---- 检测弹窗状态 ---- | |
| dialog_visible = await self._is_ems_dialog_visible(page) | |
| # 检测二维码区域(说明可以扫码了) | |
| has_qr: bool = False | |
| try: | |
| qr_loc = page.locator("text=扫码登录") | |
| if await qr_loc.count() > 0 and await qr_loc.first.is_visible(): | |
| has_qr = True | |
| except Exception: | |
| pass | |
| # 检测待处理的 checkbox | |
| has_checkbox: bool = False | |
| try: | |
| cb_loc = page.get_by_text("请阅读并同意", exact=False) | |
| if await cb_loc.count() > 0 and await cb_loc.first.is_visible(): | |
| has_checkbox = True | |
| except Exception: | |
| pass | |
| logger.info( | |
| "EMS login round %d: dialog=%s, qr=%s, checkbox=%s", | |
| attempt + 1, | |
| dialog_visible, | |
| has_qr, | |
| has_checkbox, | |
| ) | |
| # 二维码出现且无待处理 checkbox → 可以等扫码了 | |
| if has_qr and not has_checkbox: | |
| logger.info("QR code visible, waiting for user scan...") | |
| break # 跳出协议循环,进入等待扫码阶段 | |
| # 弹窗不可见 → 尝试重新打开 | |
| if not dialog_visible: | |
| reclicked = await self._ems_click_login_button(page) | |
| if reclicked: | |
| await asyncio.sleep(1.5) | |
| await asyncio.sleep(1) | |
| continue | |
| # ---- 尝试点击协议 checkbox ---- | |
| cb_clicked = await self._ems_try_agreement_checkbox(page) | |
| # 等待可能的协议页面弹出 | |
| await asyncio.sleep(1.5) | |
| # 检测新标签页(排除当前 page) | |
| new_tab = None | |
| for p in context.pages: | |
| if p is not page and not p.is_closed(): | |
| new_tab = p | |
| break | |
| if new_tab is not None: | |
| logger.info("New agreement tab detected: %s", new_tab.url) | |
| try: | |
| await self._ems_accept_agreement_on_page(new_tab) | |
| if not new_tab.is_closed(): | |
| await new_tab.close() | |
| logger.info("Agreement tab closed") | |
| await asyncio.sleep(1) | |
| except Exception as e: | |
| logger.info("Agreement tab handling failed: %s", e) | |
| if cb_clicked or has_qr or not dialog_visible: | |
| await asyncio.sleep(2) | |
| else: | |
| logger.info("No change detected, waiting...") | |
| await asyncio.sleep(2) | |
| # ===== 等待用户扫码登录 ===== | |
| remaining_secs = max(int(deadline - asyncio.get_running_loop().time()), 0) | |
| logger.info("Waiting for QR code scan (max %ds)...", remaining_secs) | |
| while asyncio.get_running_loop().time() < deadline: | |
| await asyncio.sleep(3) | |
| still_dialog = await self._is_ems_dialog_visible(page) | |
| on_query = "query_express_delivery" in page.url.lower() | |
| body = "" | |
| try: | |
| body = (await page.locator("body").text_content()) or "" | |
| except Exception: | |
| pass | |
| has_logged_in_indicators = any(kw in body for kw in ("退出", "我的EMS", "个人中心", "我的快递")) | |
| # 弹窗关闭 + 在查询页 + 有内容 → 登录成功 | |
| if not still_dialog and on_query and ("邮件号" in body or "物流" in body or "收件" in body): | |
| logger.info("Login success (dialog closed, on query page)") | |
| return | |
| # 跳到 personal_center → 也算成功 | |
| if "personal_center" in page.url.lower(): | |
| logger.info("Login success (redirected to personal_center)") | |
| return | |
| # 有已登录标志且弹窗不在了 | |
| if not still_dialog and has_logged_in_indicators: | |
| logger.info("Login success (logged-in indicators found)") | |
| return | |
| remaining_secs = max(int(deadline - asyncio.get_running_loop().time()), 0) | |
| logger.info("Waiting for scan... (%ds remaining)", remaining_secs) | |
| raise TimeoutError("EMS login timed out after %ds" % timeout_seconds) | |
| async def _ems_try_agreement_checkbox(self, page: Page) -> bool: | |
| """尝试多种策略点击 EMS 协议 checkbox。返回是否点击成功。""" | |
| # 策略A: XPath 点击 span | |
| xpaths: list[str] = [ | |
| "//label[contains(., '同意')]/span/span", | |
| "//label[contains(., '服务协议')]//span[@class]", | |
| ] | |
| for xp in xpaths: | |
| try: | |
| loc = page.locator(f"xpath={xp}") | |
| if await loc.count() > 0: | |
| await loc.first.click(force=True, timeout=2000) | |
| logger.info("Agreement clicked via XPath: %s", xp) | |
| await asyncio.sleep(2) | |
| return True | |
| except Exception as e: | |
| logger.info("XPath failed: %s", e) | |
| # 策略B: 文本定位整行 | |
| try: | |
| text_loc = page.get_by_text("请阅读并同意", exact=False) | |
| if await text_loc.count() > 0: | |
| await text_loc.first.click(force=True, timeout=2000) | |
| logger.info("Agreement clicked via text") | |
| await asyncio.sleep(2) | |
| return True | |
| except Exception as e: | |
| logger.info("Text click failed: %s", e) | |
| # 策略C: JS 在弹窗内搜索 | |
| try: | |
| js_res = await page.evaluate("""() => { | |
| const dialogs = document.querySelectorAll('.el-dialog'); | |
| for (const d of dialogs) { | |
| if (d.getBoundingClientRect().width === 0) continue; | |
| const candidates = Array.from(d.querySelectorAll( | |
| 'label, span, div, [class*="check"], [class*="agree"]' | |
| )).filter(el => { | |
| const t = el.innerText || ''; | |
| return (t.includes('同意') || t.includes('协议')) | |
| && el.getBoundingClientRect().width > 0; | |
| }); | |
| if (candidates.length > 0) { candidates[0].click(); return { ok: true }; } | |
| } | |
| const all = Array.from(document.querySelectorAll('*')).filter(el => { | |
| const t = el.innerText || ''; | |
| return (t.includes('请阅读并同意') || t.includes('登录即代表')) | |
| && el.getBoundingClientRect().width > 0 && el.children.length < 5; | |
| }); | |
| all.sort((a, b) => a.innerText.length - b.innerText.length); | |
| if (all.length > 0) { all[0].click(); return { ok: true }; } | |
| return { ok: false }; | |
| }""") | |
| if js_res.get("ok"): | |
| logger.info("Agreement clicked via JS") | |
| await asyncio.sleep(2) | |
| return True | |
| except Exception as e: | |
| logger.info("JS click failed: %s", e) | |
| return False | |
| async def _ems_accept_agreement_on_page(self, agreement_page: Page) -> None: | |
| """在协议页面上点最后一个条款 + 点确认按钮。""" | |
| last_clause_selectors: list[str] = [ | |
| "xpath=//*[@id='app']/div[2]/div/div[2]/div/div[1]/div[1]/ul/li[last()]/div/p", | |
| "xpath=//*[@id='app']/div[2]/div/div[2]/div/div[1]/div[1]/ul/li[5]/div/p", | |
| ] | |
| for sel in last_clause_selectors: | |
| try: | |
| clause = agreement_page.locator(sel) | |
| if await clause.count() > 0: | |
| await clause.first.click() | |
| await asyncio.sleep(0.5) | |
| break | |
| except Exception: | |
| continue | |
| accept_selectors: list[str] = [ | |
| "xpath=//*[@id='app']/div[2]/div/div[2]/div/div[1]/div[2]/div[3]/button[last()]", | |
| "xpath=//*[@id='app']/div[2]/div/div[2]/div/div[1]/div[2]/div[3]/button[2]", | |
| ] | |
| for sel in accept_selectors: | |
| try: | |
| btn = agreement_page.locator(sel) | |
| if await btn.count() > 0: | |
| await btn.first.click(timeout=3000) | |
| logger.info("Agreement accept button clicked") | |
| await asyncio.sleep(1) | |
| return | |
| except Exception: | |
| continue | |
| logger.warning("Accept button not found on agreement page") | |
| async def _click_locator_if_visible(locator: Any, description: str) -> bool: | |
| """点击 Locator 中第一个可见元素。""" | |
| try: | |
| count = await locator.count() | |
| except Exception: | |
| return False | |
| for index in range(min(count, 5)): | |
| target = locator.nth(index) | |
| try: | |
| if not await target.is_visible(): | |
| continue | |
| await target.scroll_into_view_if_needed() | |
| await target.click(force=True, timeout=2000) | |
| logger.info(" Clicked %s", description) | |
| await asyncio.sleep(1) | |
| return True | |
| except Exception: | |
| continue | |
| return False | |
| async def _fill_first(self, page: Page, selectors: list[str], value: str) -> None: | |
| for selector in selectors: | |
| locator = page.locator(selector) | |
| try: | |
| if await locator.count() == 0: | |
| continue | |
| first = locator.first | |
| await first.click(force=True) | |
| await first.fill("") | |
| await first.fill(value) | |
| return | |
| except Exception: | |
| continue | |
| raise RuntimeError("No input field found") | |
| async def _click_first(self, page: Page, selectors: list[str]) -> bool: | |
| for selector in selectors: | |
| locator = page.locator(selector) | |
| try: | |
| count = await locator.count() | |
| except Exception: | |
| continue | |
| for index in range(min(count, 5)): | |
| candidate = locator.nth(index) | |
| try: | |
| if not await candidate.is_visible(): | |
| continue | |
| await candidate.scroll_into_view_if_needed() | |
| await candidate.click(force=True, timeout=2000) | |
| return True | |
| except Exception: | |
| continue | |
| return False | |
| async def _has_any_visible(self, page: Page, selectors: list[str]) -> bool: | |
| for selector in selectors: | |
| locator = page.locator(selector) | |
| try: | |
| count = await locator.count() | |
| except Exception: | |
| continue | |
| for index in range(min(count, 3)): | |
| try: | |
| if await locator.nth(index).is_visible(): | |
| return True | |
| except Exception: | |
| continue | |
| return False | |