chore: auto release commit (2026-07-03 12:09:55)
All checks were successful
技能自动化发布 / release (push) Successful in 5s
All checks were successful
技能自动化发布 / release (push) Successful in 5s
This commit is contained in:
356
scripts/service/amazon_report_adapter/simulator_rpa.py
Normal file
356
scripts/service/amazon_report_adapter/simulator_rpa.py
Normal file
@@ -0,0 +1,356 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user