357 lines
14 KiB
Python
357 lines
14 KiB
Python
"""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
|