Files
skill-template/scripts/service/amazon_report_adapter/simulator_rpa.py
chendelian 4a0be93b4a
All checks were successful
技能自动化发布 / release (push) Successful in 5s
chore: auto release commit (2026-07-03 12:09:55)
2026-07-03 12:09:55 +08:00

357 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Amazon 仿真 Reports 页 Playwright RPA请求并下载结算报表 xlsx。
定位原则:优先 data-testidF12 实测),禁止 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 分类 Tabreports-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