"""返利中心查号:打开、登录、进采购协议、按合同号搜。不录协议。""" from __future__ import annotations import time from dataclasses import dataclass from typing import Protocol from accept import AcceptanceReport, evidence_shot from errors import LoginFailed FLOW = "REBATE_FIND" DEFAULT_URL = "http://192.168.170.144:8001" SENTINEL_CONTRACT_NO = "__NO_SUCH_CONTRACT__" _ACCEPT = AcceptanceReport(flow=FLOW) _PORTAL_MARKERS = ("采购协议", "待审批", "待审批事项", "待办") EMPTY_MARKERS = ("暂无数据", "没有找到", "查询无结果", "无匹配数据", "未找到相关") LOGIN = { "user": [ 'input[name="username"]', 'input[name="userName"]', 'input[placeholder*="用户名"]', 'input[placeholder*="账号"]', 'input[placeholder*="工号"]', 'input[type="text"]', ], "password": [ 'input[name="password"]', 'input[placeholder*="密码"]', 'input[type="password"]', ], "submit": [ "text=登录", "button:contains(登录)", 'button[type="submit"]', 'input[type="submit"]', 'input[value="登录"]', ], } MENU = { "entry": [ "text=采购协议管理", "text=采购协议", "a:contains(采购协议管理)", "span:contains(采购协议管理)", ], } SEARCH = { "contract_no": [ "label=合同编号", "label=合同号", 'input[placeholder*="合同编号"]', 'input[placeholder*="合同号"]', 'input[name*="contractNo"]', 'input[name*="contract"]', ], "button": [ "text=查询", "button:contains(查询)", "a:contains(查询)", "text=搜索", "button:contains(搜索)", ], } _TABLE_SCRIPT = """ const nodes = Array.from(document.querySelectorAll( 'table tbody, table, .el-table__body, .el-table, .ant-table-tbody, .ant-table' )); return nodes.map((n) => (n.innerText || '').trim()).filter(Boolean).join('\\n'); """ class BrowserAdapter(Protocol): def open_url(self, url: str) -> None: ... def fill_first(self, selectors: list[str], value: str) -> None: ... def click_first(self, selectors: list[str]) -> None: ... def wait_for_text(self, text: str, timeout_seconds: int = 15) -> None: ... def wait_for_element(self, selectors: list[str], timeout_seconds: int = 15) -> None: ... def element_exists(self, selectors: list[str]) -> bool: ... def read_body_text(self) -> str: ... def execute_script(self, script: str, *args): ... def screenshot(self, name: str) -> str: ... @dataclass(frozen=True) class RebateFlowContext: rebate_url: str username: str password: str def classify_search_result(result_text: str, contract_no: str, *, from_table: bool = True) -> bool: number = (contract_no or "").strip() if not number: return False text = result_text or "" if from_table: if any(marker in text for marker in EMPTY_MARKERS) and number not in text: return False return number in text if any(marker in text for marker in EMPTY_MARKERS): return False return text.count(number) >= 2 def read_result_table(browser: BrowserAdapter) -> tuple[str, bool]: try: text = browser.execute_script(_TABLE_SCRIPT) except Exception: text = None if isinstance(text, str) and text.strip(): return text, True return (browser.read_body_text() or ""), False def open_rebate_center(browser: BrowserAdapter, ctx: RebateFlowContext) -> None: browser.open_url(ctx.rebate_url) def _is_logged_in(browser: BrowserAdapter) -> bool: body = browser.read_body_text() or "" return any(marker in body for marker in _PORTAL_MARKERS) def login_rebate_center(browser: BrowserAdapter, ctx: RebateFlowContext) -> None: try: if _is_logged_in(browser): return if not (ctx.username or "").strip() or ctx.password == "": raise LoginFailed("未配置 REBATE_USERNAME / REBATE_PASSWORD") browser.fill_first(LOGIN["user"], ctx.username) browser.fill_first(LOGIN["password"], ctx.password) browser.click_first(LOGIN["submit"]) deadline = time.time() + 20 while time.time() < deadline: if _is_logged_in(browser): return time.sleep(0.5) raise TimeoutError("登录后未进入返利中心") except LoginFailed: raise except Exception as exc: raise LoginFailed(f"返利中心登录失败:{exc}") from exc def enter_purchase_agreement(browser: BrowserAdapter) -> None: body = browser.read_body_text() or "" if "合同编号" in body or "合同号" in body: return browser.click_first(MENU["entry"]) browser.wait_for_text("查询", timeout_seconds=15) def search_contract(browser: BrowserAdapter, contract_no: str) -> bool: number = (contract_no or "").strip() if not number: return False browser.fill_first(SEARCH["contract_no"], number) browser.click_first(SEARCH["button"]) deadline = time.time() + 15 last_text = "" last_from_table = False while time.time() <= deadline: text, from_table = read_result_table(browser) last_text, last_from_table = text, from_table found = classify_search_result(text, number, from_table=from_table) empty = any(marker in (text or "") for marker in EMPTY_MARKERS) if found or empty: return found time.sleep(0.4) return classify_search_result(last_text, number, from_table=last_from_table) def run_find_flow( browser: BrowserAdapter, ctx: RebateFlowContext, contract_no: str, *, expect_found: bool | None = None, ) -> bool: _ACCEPT.reset() try: open_rebate_center(browser, ctx) _ACCEPT.note("S02", "打开返利中心", {"url": ctx.rebate_url}, ok=True) try: login_rebate_center(browser, ctx) _ACCEPT.note("S03", "登录", {"user": ctx.username}, ok=True) except Exception as exc: evidence_shot(browser, "rebate-login-failed", failed=True) _ACCEPT.note( "S03", "登录", {"user": ctx.username, "error": exc}, ok=False, raise_error=False, ) raise enter_purchase_agreement(browser) _ACCEPT.note("S04", "进入采购协议", {"menu": "采购协议管理"}, ok=True) found = search_contract(browser, contract_no) found_text = "yes" if found else "no" if expect_found is None: _ACCEPT.note( "S05", "查号", {"contractNo": contract_no, "found": found_text}, ok=True, ) else: _ACCEPT.check( "S05", "查号", [ ("contractNo", contract_no, contract_no), ("found", found_text, "yes" if expect_found else "no"), ], browser=browser, screenshot_on_fail="rebate-find-mismatch", ) return found finally: print(_ACCEPT.summary_text(), end="", flush=True) _ACCEPT.dump()