"""Amazon 仿真 Reports 页 Playwright RPA:请求并下载结算报表 xlsx。 定位原则:优先 data-testid(F12 实测),禁止 el.value= / JS 跳转 / REST API 下载。 """ from __future__ import annotations import logging import os import random import time from typing import Optional from jiangchang_skill_core import config from service.account_client import ( AccountManagerError, get_account_credential, inject_account_manager_scripts_path, pick_web_account, release_lease, ) from service.amazon_report_adapter.base import DownloadSettlementAdapterBase, DownloadSettlementResult from service.browser_session import find_chrome_executable from util.constants import ( DEFAULT_TIMEOUT_MS, JOB_POLL_INTERVAL_SEC, JOB_POLL_TIMEOUT_SEC, REPORTS_NAVIGATE_TIMEOUT_MS, TARGET_PLATFORM, resolve_amazon_sim_login_url, resolve_amazon_sim_reports_url, ) logger = logging.getLogger(__name__) _PAUSE_MIN_MS = 500 _PAUSE_MAX_MS = 2000 class RpaError(Exception): def __init__(self, message: str, screenshot_path: Optional[str] = None): super().__init__(message) self.screenshot_path = screenshot_path class SimulatorRpaDownloadSettlementAdapter(DownloadSettlementAdapterBase): name = "simulator_rpa" def __init__( self, *, reports_url: Optional[str] = None, login_url: Optional[str] = None, headless: Optional[bool] = None, artifacts_dir: Optional[str] = None, ) -> None: self.reports_url = (reports_url or resolve_amazon_sim_reports_url()).strip() self.login_url = (login_url or resolve_amazon_sim_login_url()).strip() if headless is None: self.headless = config.get_bool("OPENCLAW_BROWSER_HEADLESS", default=False) else: self.headless = headless self.artifacts_dir = artifacts_dir def download_settlement( self, *, date_from: str, date_to: str, seller_code: Optional[str] = None, downloads_dir: str = "", artifacts_dir: str = "", ) -> DownloadSettlementResult: art_dir = artifacts_dir or self.artifacts_dir or "" os.makedirs(downloads_dir, exist_ok=True) if art_dir: os.makedirs(art_dir, exist_ok=True) try: from playwright.sync_api import sync_playwright # noqa: F401 except ImportError: return DownloadSettlementResult( ok=False, job_id=None, file_path=None, filename=None, date_from=date_from, date_to=date_to, seller_code=seller_code, error_msg=( "playwright Python 包不可用。" "生产/宿主运行由共享 runtime 提供;技能侧不要 pip install playwright。" ), artifacts={"adapter": self.name}, ) if not find_chrome_executable(): return DownloadSettlementResult( ok=False, job_id=None, file_path=None, filename=None, date_from=date_from, date_to=date_to, seller_code=seller_code, error_msg="未检测到 Chrome 或 Edge 浏览器,请安装系统浏览器后重试。", artifacts={"adapter": self.name}, ) lease_token: Optional[str] = None ctx = None try: account = pick_web_account(TARGET_PLATFORM) lease_token = str(account.get("lease_token") or "").strip() or None profile_dir = str(account.get("profile_dir") or "").strip() account_id = account.get("id") or account.get("account_id") credentials: dict = {} if lease_token and account_id is not None: try: cred = get_account_credential(int(account_id), lease_token) plaintext = cred.get("plaintext") if plaintext: credentials = {"plaintext": plaintext} except AccountManagerError as exc: logger.warning("get_credential_failed: %s", exc.message) inject_account_manager_scripts_path() from service.rpa_helpers import close_browser, ensure_logged_in, launch_browser_with_profile login_start = str(account.get("url") or self.login_url).strip() ctx, page = launch_browser_with_profile(profile_dir, headless=self.headless) try: if login_start: page.goto(login_start, wait_until="domcontentloaded", timeout=REPORTS_NAVIGATE_TIMEOUT_MS) login_result = ensure_logged_in(page, account, credentials=credentials or None) if not login_result.ok: sp = self._safe_screenshot(page, "login_failed", art_dir) raise RpaError(login_result.message or "登录失败", screenshot_path=sp) self._random_delay() page.goto(self.reports_url, wait_until="domcontentloaded", timeout=REPORTS_NAVIGATE_TIMEOUT_MS) job_id, file_path, filename = self._run_reports_flow( page, date_from, date_to, seller_code, downloads_dir, art_dir ) return DownloadSettlementResult( ok=True, job_id=job_id, file_path=file_path, filename=filename, date_from=date_from, date_to=date_to, seller_code=seller_code, error_msg=None, artifacts={"adapter": self.name}, ) except RpaError as exc: arts = {"adapter": self.name} if exc.screenshot_path: arts["screenshot"] = exc.screenshot_path return DownloadSettlementResult( ok=False, job_id=None, file_path=None, filename=None, date_from=date_from, date_to=date_to, seller_code=seller_code, error_msg=str(exc), artifacts=arts, ) except Exception as exc: sp = self._safe_screenshot(page, "unexpected_error", art_dir) logger.exception("rpa_unexpected: %s", exc) arts = {"adapter": self.name} if sp: arts["screenshot"] = sp return DownloadSettlementResult( ok=False, job_id=None, file_path=None, filename=None, date_from=date_from, date_to=date_to, seller_code=seller_code, error_msg=f"未预期异常:{type(exc).__name__}: {exc}", artifacts=arts, ) finally: close_browser(ctx) except AccountManagerError as exc: return DownloadSettlementResult( ok=False, job_id=None, file_path=None, filename=None, date_from=date_from, date_to=date_to, seller_code=seller_code, error_msg=f"ERROR:{exc.code} {exc.message}", artifacts={"adapter": self.name}, ) except Exception as outer: return DownloadSettlementResult( ok=False, job_id=None, file_path=None, filename=None, date_from=date_from, date_to=date_to, seller_code=seller_code, error_msg=f"启动浏览器失败:{outer}", artifacts={"adapter": self.name}, ) finally: release_lease(lease_token) def _random_delay(self) -> None: lo = float(config.get("STEP_DELAY_MIN") or "1.0") hi = float(config.get("STEP_DELAY_MAX") or "5.0") if hi < lo: hi = lo time.sleep(random.uniform(lo, hi)) def _switch_to_payments_tab(self, page) -> None: """Settlement 在 Payments 分类下;默认 Tab 为 Fulfillment。""" tab = page.locator('[data-testid="reports-tab-payments"]') if tab.count() > 0: self._random_delay() tab.first.click() return btn = page.get_by_role("button", name="Payments") if btn.count() > 0: self._random_delay() btn.first.click() return raise RpaError( "未找到 Payments 分类 Tab(reports-tab-payments 或 Payments 按钮不可见)。" "线上仿真 Reports 页可能尚未渲染 Tab 切换 UI,见 development/REQUIREMENTS.md §9。" ) def _run_reports_flow( self, page, date_from: str, date_to: str, seller_code: Optional[str], downloads_dir: str, art_dir: str, ) -> tuple[str, str, str]: try: page.wait_for_selector('[data-testid="reports-page-root"]', timeout=DEFAULT_TIMEOUT_MS) except Exception as exc: sp = self._safe_screenshot(page, "reports_page_not_ready", art_dir) raise RpaError(f"Reports 页未就绪:{exc}", screenshot_path=sp) from exc self._random_delay() self._switch_to_payments_tab(page) try: settlement = page.locator('[data-testid="reports-type-item-settlement"]') settlement.wait_for(timeout=DEFAULT_TIMEOUT_MS) self._random_delay() settlement.click() except Exception as exc: sp = self._safe_screenshot(page, "settlement_type_not_visible", art_dir) raise RpaError(f"未找到结算报表类型按钮:{exc}", screenshot_path=sp) from exc try: page.locator('[data-testid="reports-form-date-from"]').fill(date_from) self._random_delay() page.locator('[data-testid="reports-form-date-to"]').fill(date_to) except Exception as exc: sp = self._safe_screenshot(page, "fill_dates_failed", art_dir) raise RpaError(f"填写日期失败:{exc}", screenshot_path=sp) from exc if seller_code: try: page.locator('[data-testid="reports-form-seller-select"]').select_option(value=seller_code) self._random_delay() except Exception as exc: sp = self._safe_screenshot(page, "select_seller_failed", art_dir) raise RpaError(f"选择店铺失败:{exc}", screenshot_path=sp) from exc try: self._random_delay() page.locator('[data-testid="reports-form-submit"]').click() except Exception as exc: sp = self._safe_screenshot(page, "submit_failed", art_dir) raise RpaError(f"点击请求报表失败:{exc}", screenshot_path=sp) from exc job_id = self._wait_for_done_job(page, art_dir) file_path, filename = self._download_xlsx(page, job_id, downloads_dir, art_dir) return job_id, file_path, filename def _wait_for_done_job(self, page, art_dir: str) -> str: deadline = time.time() + JOB_POLL_TIMEOUT_SEC last_err: Optional[Exception] = None while time.time() < deadline: try: done = page.locator('[data-testid^="reports-jobs-status-"][data-status="DONE"]') if done.count() > 0: testid = done.first.get_attribute("data-testid") or "" prefix = "reports-jobs-status-" if testid.startswith(prefix): return testid[len(prefix) :] return testid except Exception as exc: last_err = exc time.sleep(JOB_POLL_INTERVAL_SEC) sp = self._safe_screenshot(page, "job_poll_timeout", art_dir) msg = f"等待报表生成超时({JOB_POLL_TIMEOUT_SEC}s)" if last_err: msg = f"{msg}:{last_err}" raise RpaError(msg, screenshot_path=sp) def _download_xlsx( self, page, job_id: str, downloads_dir: str, art_dir: str, ) -> tuple[str, str]: download_sel = f'[data-testid="reports-jobs-download-{job_id}"]' try: link = page.locator(download_sel) if link.count() == 0: link = page.locator('[data-testid^="reports-jobs-download-"]').first link.wait_for(timeout=DEFAULT_TIMEOUT_MS) suggested = link.get_attribute("data-filename") or f"settlement_{job_id}.xlsx" self._random_delay() with page.expect_download(timeout=REPORTS_NAVIGATE_TIMEOUT_MS) as dl_info: link.click() download = dl_info.value filename = download.suggested_filename or suggested file_path = os.path.join(downloads_dir, filename) download.save_as(file_path) logger.info("download_saved path=%s job_id=%s", file_path, job_id) return file_path, filename except Exception as exc: sp = self._safe_screenshot(page, "download_failed", art_dir) raise RpaError(f"下载 xlsx 失败:{exc}", screenshot_path=sp) from exc def _safe_screenshot(self, page, tag: str, artifacts_dir: str) -> Optional[str]: if not artifacts_dir: return None try: os.makedirs(artifacts_dir, exist_ok=True) path = os.path.join(artifacts_dir, f"{tag}_{int(time.time())}.png") page.screenshot(path=path, full_page=True) logger.info("screenshot_saved path=%s", path) return path except Exception as exc: logger.warning("screenshot_failed tag=%s err=%s", tag, exc) return None