chore: auto release commit (2026-07-03 12:09:55)
All checks were successful
技能自动化发布 / release (push) Successful in 5s

This commit is contained in:
2026-07-03 12:09:55 +08:00
parent 7136689efe
commit 4a0be93b4a
22 changed files with 1613 additions and 575 deletions

View File

@@ -0,0 +1,356 @@
"""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