chore: auto release commit (2026-09-14 15:11:41)
All checks were successful
技能自动化发布 / release (push) Successful in 9s
All checks were successful
技能自动化发布 / release (push) Successful in 9s
This commit is contained in:
18
scripts/service/rpa/__init__.py
Normal file
18
scripts/service/rpa/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""RPA模块:提供发票信息提取的自动化功能。"""
|
||||
|
||||
from .browser_session import close_browser_context, start_browser_session
|
||||
from .invoice_extractor import extract_invoice_detail, extract_invoice_list, format_invoice_data
|
||||
from .invoice_rpa import InvoiceRpaResult, run_invoice_rpa, run_invoice_rpa_with_video
|
||||
from .login_handler import login_if_needed
|
||||
|
||||
__all__ = [
|
||||
"start_browser_session",
|
||||
"close_browser_context",
|
||||
"login_if_needed",
|
||||
"extract_invoice_list",
|
||||
"extract_invoice_detail",
|
||||
"format_invoice_data",
|
||||
"run_invoice_rpa",
|
||||
"run_invoice_rpa_with_video",
|
||||
"InvoiceRpaResult",
|
||||
]
|
||||
104
scripts/service/rpa/browser_session.py
Normal file
104
scripts/service/rpa/browser_session.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""浏览器会话管理:基于skill-template的real_browser_rpa示例。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
from jiangchang_skill_core import config
|
||||
from jiangchang_skill_core.activity import interruptible_sleep
|
||||
from jiangchang_skill_core.rpa import launch_persistent_browser, poll_until
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 目标网站配置
|
||||
TARGET_URL = "https://sandbox.jc2009.com/industries/campus/smartcampus/app/finance/invoice-precheck"
|
||||
LOGIN_URL = "https://sandbox.jc2009.com/login"
|
||||
|
||||
|
||||
async def start_browser_session(
|
||||
profile_dir: str,
|
||||
headless: bool = False,
|
||||
) -> Tuple[Any, Any, Any]:
|
||||
"""启动浏览器会话。
|
||||
|
||||
Args:
|
||||
profile_dir: 浏览器配置目录
|
||||
headless: 是否无头模式(生产必须为False)
|
||||
|
||||
Returns:
|
||||
(playwright, context, page) 三元组
|
||||
|
||||
Raises:
|
||||
RuntimeError: 浏览器启动失败
|
||||
"""
|
||||
if not profile_dir:
|
||||
raise RuntimeError("ERROR:MISSING_BROWSER 缺少浏览器配置目录")
|
||||
|
||||
# 确保有头模式(生产要求)
|
||||
if headless:
|
||||
logger.warning("生产环境强制使用有头模式")
|
||||
headless = False
|
||||
|
||||
try:
|
||||
# 启动持久化浏览器上下文
|
||||
pw, context = await launch_persistent_browser(
|
||||
user_data_dir=profile_dir,
|
||||
headless=headless,
|
||||
args=[
|
||||
"--start-maximized",
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
],
|
||||
)
|
||||
|
||||
# 创建新页面
|
||||
page = await context.new_page()
|
||||
|
||||
# 导航到目标页面
|
||||
await page.goto(TARGET_URL, wait_until="domcontentloaded", timeout=60000)
|
||||
|
||||
logger.info("browser_started url=%s profile_dir=%s", page.url, profile_dir)
|
||||
return pw, context, page
|
||||
|
||||
except Exception as e:
|
||||
logger.error("browser_start_failed error=%s", str(e))
|
||||
raise RuntimeError(f"ERROR:MISSING_BROWSER 浏览器启动失败: {str(e)}")
|
||||
|
||||
|
||||
async def close_browser_context(pw: Any, context: Any) -> None:
|
||||
"""安全关闭浏览器上下文。"""
|
||||
try:
|
||||
if context:
|
||||
await context.close()
|
||||
if pw:
|
||||
await pw.stop()
|
||||
except Exception as e:
|
||||
logger.warning("browser_close_error error=%s", str(e))
|
||||
|
||||
|
||||
async def wait_for_page_ready(page: Any, timeout_sec: float = 30.0) -> bool:
|
||||
"""等待页面加载完成。"""
|
||||
async def _check_ready():
|
||||
try:
|
||||
# 检查页面是否加载完成
|
||||
if await page.evaluate("document.readyState") == "complete":
|
||||
return True
|
||||
# 检查是否有加载指示器
|
||||
loading_selectors = [".loading", ".spinner", "[data-loading]"]
|
||||
for sel in loading_selectors:
|
||||
if await page.locator(sel).count() > 0:
|
||||
return False
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
result = await poll_until(_check_ready, timeout_sec=timeout_sec, interval_sec=0.5)
|
||||
return result.ok
|
||||
|
||||
|
||||
async def random_delay(min_sec: float = 1.0, max_sec: float = 3.0) -> None:
|
||||
"""随机延迟,模拟人类操作节奏。"""
|
||||
delay = random.uniform(min_sec, max_sec)
|
||||
await interruptible_sleep(delay)
|
||||
349
scripts/service/rpa/invoice_extractor.py
Normal file
349
scripts/service/rpa/invoice_extractor.py
Normal file
@@ -0,0 +1,349 @@
|
||||
"""发票信息提取器:从网站提取发票信息。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from jiangchang_skill_core.activity import interruptible_sleep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 发票信息提取选择器(需要根据实际页面调整)
|
||||
INVOICE_SELECTORS = {
|
||||
# 发票列表容器
|
||||
"invoice_list_container": [
|
||||
".invoice-list",
|
||||
".invoice-table",
|
||||
"[data-testid='invoice-list']",
|
||||
"table tbody",
|
||||
],
|
||||
|
||||
# 单个发票行
|
||||
"invoice_row": [
|
||||
"tr.invoice-item",
|
||||
".invoice-row",
|
||||
"[data-testid='invoice-row']",
|
||||
"tbody tr",
|
||||
],
|
||||
|
||||
# 发票详情页面元素
|
||||
"invoice_detail": {
|
||||
"invoice_code": [
|
||||
".invoice-code",
|
||||
"[data-testid='invoice-code']",
|
||||
"td:has-text('发票代码') + td",
|
||||
],
|
||||
"invoice_number": [
|
||||
".invoice-number",
|
||||
"[data-testid='invoice-number']",
|
||||
"td:has-text('发票号码') + td",
|
||||
],
|
||||
"invoice_date": [
|
||||
".invoice-date",
|
||||
"[data-testid='invoice-date']",
|
||||
"td:has-text('开票日期') + td",
|
||||
],
|
||||
"total_amount": [
|
||||
".total-amount",
|
||||
"[data-testid='total-amount']",
|
||||
"td:has-text('金额') + td",
|
||||
],
|
||||
"tax_amount": [
|
||||
".tax-amount",
|
||||
"[data-testid='tax-amount']",
|
||||
"td:has-text('税额') + td",
|
||||
],
|
||||
"buyer_name": [
|
||||
".buyer-name",
|
||||
"[data-testid='buyer-name']",
|
||||
"td:has-text('购买方') + td",
|
||||
],
|
||||
"buyer_tax_no": [
|
||||
".buyer-tax-no",
|
||||
"[data-testid='buyer-tax-no']",
|
||||
"td:has-text('购买方税号') + td",
|
||||
],
|
||||
"seller_name": [
|
||||
".seller-name",
|
||||
"[data-testid='seller-name']",
|
||||
"td:has-text('销售方') + td",
|
||||
],
|
||||
"seller_tax_no": [
|
||||
".seller-tax-no",
|
||||
"[data-testid='seller-tax-no']",
|
||||
"td:has-text('销售方税号') + td",
|
||||
],
|
||||
"items": [
|
||||
".invoice-items",
|
||||
"[data-testid='invoice-items']",
|
||||
".item-list",
|
||||
],
|
||||
},
|
||||
|
||||
# 分页控件
|
||||
"pagination": {
|
||||
"next_button": [
|
||||
".pagination .next",
|
||||
"button:has-text('下一页')",
|
||||
"[data-testid='next-page']",
|
||||
],
|
||||
"page_info": [
|
||||
".page-info",
|
||||
".pagination-info",
|
||||
"[data-testid='page-info']",
|
||||
],
|
||||
},
|
||||
|
||||
# 搜索/筛选控件
|
||||
"search": {
|
||||
"search_input": [
|
||||
"input[placeholder*='搜索']",
|
||||
"input[placeholder*='查询']",
|
||||
".search-input",
|
||||
],
|
||||
"search_button": [
|
||||
"button:has-text('搜索')",
|
||||
"button:has-text('查询')",
|
||||
".search-btn",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def extract_invoice_list(page: Any) -> List[Dict[str, Any]]:
|
||||
"""提取发票列表信息。
|
||||
|
||||
Args:
|
||||
page: Playwright页面对象
|
||||
|
||||
Returns:
|
||||
发票信息列表
|
||||
"""
|
||||
invoices = []
|
||||
|
||||
try:
|
||||
# 等待页面加载
|
||||
await interruptible_sleep(2.0)
|
||||
|
||||
# 查找发票列表容器
|
||||
container = await _find_element(page, INVOICE_SELECTORS["invoice_list_container"])
|
||||
if not container:
|
||||
logger.warning("invoice_list_container_not_found")
|
||||
return []
|
||||
|
||||
# 查找所有发票行
|
||||
rows = await _find_elements(page, INVOICE_SELECTORS["invoice_row"])
|
||||
logger.info("found_invoice_rows count=%s", len(rows))
|
||||
|
||||
# 提取每行发票信息
|
||||
for i, row in enumerate(rows):
|
||||
try:
|
||||
invoice_data = await _extract_invoice_from_row(page, row, i)
|
||||
if invoice_data:
|
||||
invoices.append(invoice_data)
|
||||
except Exception as e:
|
||||
logger.warning("extract_row_failed row=%s error=%s", i, str(e))
|
||||
continue
|
||||
|
||||
# 处理分页
|
||||
has_next = await _has_next_page(page)
|
||||
while has_next:
|
||||
# 点击下一页
|
||||
next_clicked = await _click_next_page(page)
|
||||
if not next_clicked:
|
||||
break
|
||||
|
||||
await interruptible_sleep(2.0)
|
||||
|
||||
# 提取下一页数据
|
||||
rows = await _find_elements(page, INVOICE_SELECTORS["invoice_row"])
|
||||
for i, row in enumerate(rows):
|
||||
try:
|
||||
invoice_data = await _extract_invoice_from_row(page, row, i)
|
||||
if invoice_data:
|
||||
invoices.append(invoice_data)
|
||||
except Exception as e:
|
||||
logger.warning("extract_row_failed page_row=%s error=%s", i, str(e))
|
||||
continue
|
||||
|
||||
has_next = await _has_next_page(page)
|
||||
|
||||
logger.info("extraction_complete total_invoices=%s", len(invoices))
|
||||
return invoices
|
||||
|
||||
except Exception as e:
|
||||
logger.error("extract_invoice_list_failed error=%s", str(e))
|
||||
return []
|
||||
|
||||
|
||||
async def extract_invoice_detail(page: Any, invoice_index: int = 0) -> Optional[Dict[str, Any]]:
|
||||
"""提取单个发票的详细信息。
|
||||
|
||||
Args:
|
||||
page: Playwright页面对象
|
||||
invoice_index: 发票索引(从0开始)
|
||||
|
||||
Returns:
|
||||
发票详细信息字典
|
||||
"""
|
||||
try:
|
||||
# 查找发票行
|
||||
rows = await _find_elements(page, INVOICE_SELECTORS["invoice_row"])
|
||||
if invoice_index >= len(rows):
|
||||
logger.warning("invoice_index_out_of_range index=%s total=%s", invoice_index, len(rows))
|
||||
return None
|
||||
|
||||
# 点击发票行查看详情
|
||||
row = rows[invoice_index]
|
||||
await row.click()
|
||||
await interruptible_sleep(2.0)
|
||||
|
||||
# 提取详细信息
|
||||
detail = {}
|
||||
for field, selectors in INVOICE_SELECTORS["invoice_detail"].items():
|
||||
value = await _extract_text_by_selectors(page, selectors)
|
||||
if value:
|
||||
detail[field] = value
|
||||
|
||||
# 提取商品明细
|
||||
items = await _extract_invoice_items(page)
|
||||
if items:
|
||||
detail["items"] = items
|
||||
|
||||
logger.info("extract_invoice_detail success index=%s fields=%s", invoice_index, list(detail.keys()))
|
||||
return detail
|
||||
|
||||
except Exception as e:
|
||||
logger.error("extract_invoice_detail_failed error=%s", str(e))
|
||||
return None
|
||||
|
||||
|
||||
async def _extract_invoice_from_row(page: Any, row: Any, index: int) -> Optional[Dict[str, Any]]:
|
||||
"""从行元素提取发票信息。"""
|
||||
try:
|
||||
# 提取行文本
|
||||
row_text = await row.inner_text()
|
||||
|
||||
# 尝试从文本中提取关键信息
|
||||
invoice_data = {
|
||||
"row_index": index,
|
||||
"row_text": row_text.strip(),
|
||||
"raw_text": row_text,
|
||||
}
|
||||
|
||||
# 尝试提取发票号码(通常是数字)
|
||||
invoice_match = re.search(r'(\d{8,20})', row_text)
|
||||
if invoice_match:
|
||||
invoice_data["invoice_number"] = invoice_match.group(1)
|
||||
|
||||
# 尝试提取金额(包含小数点的数字)
|
||||
amount_match = re.search(r'(\d+\.?\d*)', row_text)
|
||||
if amount_match:
|
||||
invoice_data["amount"] = amount_match.group(1)
|
||||
|
||||
return invoice_data
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("extract_from_row_failed index=%s error=%s", index, str(e))
|
||||
return None
|
||||
|
||||
|
||||
async def _extract_invoice_items(page: Any) -> List[Dict[str, str]]:
|
||||
"""提取发票商品明细。"""
|
||||
items = []
|
||||
|
||||
try:
|
||||
item_containers = await _find_elements(page, INVOICE_SELECTORS["invoice_detail"]["items"])
|
||||
if not item_containers:
|
||||
return []
|
||||
|
||||
for container in item_containers:
|
||||
try:
|
||||
item_text = await container.inner_text()
|
||||
items.append({"text": item_text.strip()})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return items
|
||||
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def _has_next_page(page: Any) -> bool:
|
||||
"""检查是否有下一页。"""
|
||||
try:
|
||||
next_button = await _find_element(page, INVOICE_SELECTORS["pagination"]["next_button"])
|
||||
if next_button:
|
||||
# 检查按钮是否可用
|
||||
is_disabled = await next_button.get_attribute("disabled")
|
||||
if is_disabled is None: # 没有disabled属性
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def _click_next_page(page: Any) -> bool:
|
||||
"""点击下一页按钮。"""
|
||||
try:
|
||||
next_button = await _find_element(page, INVOICE_SELECTORS["pagination"]["next_button"])
|
||||
if next_button:
|
||||
await next_button.click()
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning("click_next_page_failed error=%s", str(e))
|
||||
return False
|
||||
|
||||
|
||||
async def _find_element(page: Any, selectors: list) -> Optional[Any]:
|
||||
"""根据选择器列表查找元素。"""
|
||||
for selector in selectors:
|
||||
try:
|
||||
element = page.locator(selector)
|
||||
if await element.count() > 0 and await element.is_visible():
|
||||
return element
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
async def _find_elements(page: Any, selectors: list) -> List[Any]:
|
||||
"""根据选择器列表查找多个元素。"""
|
||||
elements = []
|
||||
for selector in selectors:
|
||||
try:
|
||||
found_elements = await page.locator(selector).all()
|
||||
if found_elements:
|
||||
elements.extend(found_elements)
|
||||
break # 找到一组就停止
|
||||
except Exception:
|
||||
continue
|
||||
return elements
|
||||
|
||||
|
||||
async def _extract_text_by_selectors(page: Any, selectors: list) -> Optional[str]:
|
||||
"""根据选择器列表提取文本。"""
|
||||
for selector in selectors:
|
||||
try:
|
||||
element = page.locator(selector)
|
||||
if await element.count() > 0:
|
||||
text = await element.inner_text()
|
||||
if text and text.strip():
|
||||
return text.strip()
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def format_invoice_data(invoices: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""格式化发票数据为结构化格式。"""
|
||||
return {
|
||||
"total_count": len(invoices),
|
||||
"invoices": invoices,
|
||||
"extraction_complete": True,
|
||||
}
|
||||
236
scripts/service/rpa/invoice_rpa.py
Normal file
236
scripts/service/rpa/invoice_rpa.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""发票RPA主流程:协调登录、导航、提取等操作。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from jiangchang_skill_core.activity import interruptible_sleep, rpa_step
|
||||
from jiangchang_skill_core.rpa.video_session import RpaVideoSession
|
||||
|
||||
from .browser_session import close_browser_context, start_browser_session
|
||||
from .invoice_extractor import extract_invoice_detail, extract_invoice_list, format_invoice_data
|
||||
from .login_handler import login_if_needed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InvoiceRpaResult:
|
||||
"""RPA执行结果。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
success: bool,
|
||||
data: Optional[Dict[str, Any]] = None,
|
||||
error: Optional[str] = None,
|
||||
stage: Optional[str] = None,
|
||||
):
|
||||
self.success = success
|
||||
self.data = data or {}
|
||||
self.error = error
|
||||
self.stage = stage
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"success": self.success,
|
||||
"data": self.data,
|
||||
"error": self.error,
|
||||
"stage": self.stage,
|
||||
}
|
||||
|
||||
|
||||
@rpa_step("启动浏览器并登录")
|
||||
async def _start_and_login(
|
||||
profile_dir: str,
|
||||
username: str,
|
||||
password: str,
|
||||
headless: bool = False,
|
||||
) -> tuple[Any, Any, Any, bool]:
|
||||
"""启动浏览器并登录。"""
|
||||
pw, context, page = await start_browser_session(profile_dir, headless=headless)
|
||||
|
||||
# 登录
|
||||
login_success = await login_if_needed(page, username, password)
|
||||
if not login_success:
|
||||
await close_browser_context(pw, context)
|
||||
raise RuntimeError("ERROR:LOGIN_TIMEOUT 登录失败或超时")
|
||||
|
||||
return pw, context, page, True
|
||||
|
||||
|
||||
@rpa_step("导航到发票预检页面")
|
||||
async def _navigate_to_invoice_page(page: Any) -> bool:
|
||||
"""导航到发票预检页面。"""
|
||||
try:
|
||||
# 检查当前页面是否已在目标页面
|
||||
if "invoice-precheck" in page.url:
|
||||
logger.info("already_on_invoice_page url=%s", page.url)
|
||||
return True
|
||||
|
||||
# 导航到目标页面
|
||||
target_url = "https://sandbox.jc2009.com/industries/campus/smartcampus/app/finance/invoice-precheck"
|
||||
await page.goto(target_url, wait_until="domcontentloaded", timeout=60000)
|
||||
|
||||
# 等待页面加载
|
||||
await interruptible_sleep(3.0)
|
||||
|
||||
logger.info("navigated_to_invoice_page url=%s", page.url)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error("navigation_failed error=%s", str(e))
|
||||
return False
|
||||
|
||||
|
||||
@rpa_step("提取发票信息")
|
||||
async def _extract_invoices(page: Any, detail_mode: bool = False) -> Dict[str, Any]:
|
||||
"""提取发票信息。"""
|
||||
try:
|
||||
if detail_mode:
|
||||
# 提取详细信息
|
||||
invoices = []
|
||||
rows = await page.locator("tr").all()
|
||||
for i in range(len(rows)):
|
||||
detail = await extract_invoice_detail(page, i)
|
||||
if detail:
|
||||
invoices.append(detail)
|
||||
return format_invoice_data(invoices)
|
||||
else:
|
||||
# 提取列表信息
|
||||
invoices = await extract_invoice_list(page)
|
||||
return format_invoice_data(invoices)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("extraction_failed error=%s", str(e))
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
async def run_invoice_rpa(
|
||||
username: str,
|
||||
password: str,
|
||||
profile_dir: str,
|
||||
headless: bool = False,
|
||||
detail_mode: bool = False,
|
||||
data_dir: str = "",
|
||||
batch_id: str = "",
|
||||
) -> InvoiceRpaResult:
|
||||
"""运行发票RPA主流程。
|
||||
|
||||
Args:
|
||||
username: 用户名
|
||||
password: 密码
|
||||
profile_dir: 浏览器配置目录
|
||||
headless: 是否无头模式
|
||||
detail_mode: 是否提取详细信息
|
||||
data_dir: 数据目录
|
||||
batch_id: 批次ID
|
||||
|
||||
Returns:
|
||||
InvoiceRpaResult: 执行结果
|
||||
"""
|
||||
if not batch_id:
|
||||
batch_id = uuid.uuid4().hex[:12]
|
||||
|
||||
logger.info(
|
||||
"invoice_rpa_start username=%s batch_id=%s detail_mode=%s",
|
||||
username[:3] + "***", # 脱敏
|
||||
batch_id,
|
||||
detail_mode,
|
||||
)
|
||||
|
||||
pw = None
|
||||
context = None
|
||||
page = None
|
||||
|
||||
try:
|
||||
# 启动浏览器并登录
|
||||
pw, context, page, _ = await _start_and_login(
|
||||
profile_dir, username, password, headless
|
||||
)
|
||||
|
||||
# 导航到发票页面
|
||||
if not await _navigate_to_invoice_page(page):
|
||||
return InvoiceRpaResult(
|
||||
success=False,
|
||||
error="导航到发票页面失败",
|
||||
stage="navigation",
|
||||
)
|
||||
|
||||
# 提取发票信息
|
||||
result_data = await _extract_invoices(page, detail_mode)
|
||||
|
||||
if "error" in result_data:
|
||||
return InvoiceRpaResult(
|
||||
success=False,
|
||||
error=result_data["error"],
|
||||
stage="extraction",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"invoice_rpa_success total_invoices=%s batch_id=%s",
|
||||
result_data.get("total_count", 0),
|
||||
batch_id,
|
||||
)
|
||||
|
||||
return InvoiceRpaResult(
|
||||
success=True,
|
||||
data=result_data,
|
||||
stage="completed",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("invoice_rpa_failed error=%s batch_id=%s", str(e), batch_id)
|
||||
return InvoiceRpaResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
stage="unknown",
|
||||
)
|
||||
|
||||
finally:
|
||||
# 关闭浏览器
|
||||
if pw and context:
|
||||
await close_browser_context(pw, context)
|
||||
|
||||
|
||||
async def run_invoice_rpa_with_video(
|
||||
username: str,
|
||||
password: str,
|
||||
profile_dir: str,
|
||||
skill_slug: str,
|
||||
skill_data_dir: str,
|
||||
headless: bool = False,
|
||||
detail_mode: bool = False,
|
||||
title: str = "开始提取发票信息",
|
||||
closing_title: str = "发票信息提取完成",
|
||||
) -> InvoiceRpaResult:
|
||||
"""带录屏的发票RPA主流程。"""
|
||||
batch_id = uuid.uuid4().hex[:12]
|
||||
|
||||
async with RpaVideoSession(
|
||||
skill_slug=skill_slug,
|
||||
skill_data_dir=skill_data_dir,
|
||||
batch_id=batch_id,
|
||||
title=title,
|
||||
closing_title=closing_title,
|
||||
) as video:
|
||||
video.add_step("准备启动浏览器")
|
||||
|
||||
# 运行RPA
|
||||
result = await run_invoice_rpa(
|
||||
username=username,
|
||||
password=password,
|
||||
profile_dir=profile_dir,
|
||||
headless=headless,
|
||||
detail_mode=detail_mode,
|
||||
data_dir=skill_data_dir,
|
||||
batch_id=batch_id,
|
||||
)
|
||||
|
||||
if result.success:
|
||||
video.add_step(f"成功提取 {result.data.get('total_count', 0)} 张发票信息")
|
||||
else:
|
||||
video.add_step(f"提取失败: {result.error}")
|
||||
|
||||
return result
|
||||
208
scripts/service/rpa/login_handler.py
Normal file
208
scripts/service/rpa/login_handler.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""登录处理器:处理网站登录流程。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
from jiangchang_skill_core.activity import interruptible_sleep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 登录相关选择器(需要根据实际页面调整)
|
||||
LOGIN_SELECTORS = {
|
||||
"username_input": [
|
||||
"input[name='username']",
|
||||
"input[placeholder*='用户名']",
|
||||
"input[placeholder*='账号']",
|
||||
"#username",
|
||||
"[data-testid='username']",
|
||||
],
|
||||
"password_input": [
|
||||
"input[name='password']",
|
||||
"input[type='password']",
|
||||
"#password",
|
||||
"[data-testid='password']",
|
||||
],
|
||||
"login_button": [
|
||||
"button[type='submit']",
|
||||
"button:has-text('登录')",
|
||||
"input[type='submit']",
|
||||
".login-btn",
|
||||
"[data-testid='login-button']",
|
||||
],
|
||||
"captcha_input": [
|
||||
"input[name='captcha']",
|
||||
"input[placeholder*='验证码']",
|
||||
"#captcha",
|
||||
],
|
||||
"captcha_image": [
|
||||
".captcha-image",
|
||||
"img[alt*='验证码']",
|
||||
"#captcha-image",
|
||||
],
|
||||
}
|
||||
|
||||
# 登录成功检测
|
||||
LOGIN_SUCCESS_SELECTORS = [
|
||||
".user-info",
|
||||
".welcome",
|
||||
"[data-testid='user-avatar']",
|
||||
"a[href*='logout']",
|
||||
".dashboard",
|
||||
]
|
||||
|
||||
|
||||
async def login_if_needed(
|
||||
page: Any,
|
||||
username: str,
|
||||
password: str,
|
||||
wait_sec: int = 120,
|
||||
) -> bool:
|
||||
"""检查登录状态,如需要则执行登录。
|
||||
|
||||
Args:
|
||||
page: Playwright页面对象
|
||||
username: 用户名
|
||||
password: 密码
|
||||
wait_sec: 等待人工验证的超时时间
|
||||
|
||||
Returns:
|
||||
bool: 登录是否成功
|
||||
"""
|
||||
# 检查是否已登录
|
||||
if await _is_logged_in(page):
|
||||
logger.info("already_logged_in url=%s", page.url)
|
||||
return True
|
||||
|
||||
# 导航到登录页面
|
||||
if "/login" not in page.url:
|
||||
await page.goto("https://sandbox.jc2009.com/login", wait_until="domcontentloaded")
|
||||
await interruptible_sleep(2.0)
|
||||
|
||||
# 尝试自动登录
|
||||
login_success = await _try_auto_login(page, username, password)
|
||||
if login_success:
|
||||
return True
|
||||
|
||||
# 等待人工登录
|
||||
return await _wait_for_human_login(page, wait_sec)
|
||||
|
||||
|
||||
async def _try_auto_login(page: Any, username: str, password: str) -> bool:
|
||||
"""尝试自动登录。"""
|
||||
try:
|
||||
# 查找用户名输入框
|
||||
username_input = await _find_element(page, LOGIN_SELECTORS["username_input"])
|
||||
if not username_input:
|
||||
logger.warning("username_input_not_found")
|
||||
return False
|
||||
|
||||
# 查找密码输入框
|
||||
password_input = await _find_element(page, LOGIN_SELECTORS["password_input"])
|
||||
if not password_input:
|
||||
logger.warning("password_input_not_found")
|
||||
return False
|
||||
|
||||
# 输入用户名(模拟人类输入)
|
||||
await username_input.click()
|
||||
await interruptible_sleep(0.5)
|
||||
await username_input.fill("")
|
||||
await page.keyboard.type(username, delay=100)
|
||||
await interruptible_sleep(0.5)
|
||||
|
||||
# 输入密码
|
||||
await password_input.click()
|
||||
await interruptible_sleep(0.5)
|
||||
await password_input.fill("")
|
||||
await page.keyboard.type(password, delay=100)
|
||||
await interruptible_sleep(0.5)
|
||||
|
||||
# 查找并点击登录按钮
|
||||
login_button = await _find_element(page, LOGIN_SELECTORS["login_button"])
|
||||
if not login_button:
|
||||
logger.warning("login_button_not_found")
|
||||
return False
|
||||
|
||||
await login_button.click()
|
||||
await interruptible_sleep(3.0)
|
||||
|
||||
# 检查是否登录成功
|
||||
return await _is_logged_in(page)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("auto_login_failed error=%s", str(e))
|
||||
return False
|
||||
|
||||
|
||||
async def _wait_for_human_login(page: Any, wait_sec: int) -> bool:
|
||||
"""等待人工完成登录。"""
|
||||
print(f"请在浏览器中完成登录,最多等待 {wait_sec} 秒...")
|
||||
deadline = time.monotonic() + wait_sec
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
if await _is_logged_in(page):
|
||||
print("检测到登录成功")
|
||||
return True
|
||||
|
||||
# 检查是否有验证码需要处理
|
||||
captcha_present = await _check_captcha(page)
|
||||
if captcha_present:
|
||||
print("检测到验证码,请人工完成验证...")
|
||||
|
||||
await interruptible_sleep(2.0)
|
||||
|
||||
print("登录超时")
|
||||
return False
|
||||
|
||||
|
||||
async def _is_logged_in(page: Any) -> bool:
|
||||
"""检查是否已登录。"""
|
||||
try:
|
||||
# 检查URL是否包含登录成功标识
|
||||
if "/login" not in page.url and "/index" in page.url:
|
||||
return True
|
||||
|
||||
# 检查页面元素
|
||||
for selector in LOGIN_SUCCESS_SELECTORS:
|
||||
try:
|
||||
element = page.locator(selector)
|
||||
if await element.count() > 0 and await element.is_visible():
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def _check_captcha(page: Any) -> bool:
|
||||
"""检查是否有验证码。"""
|
||||
try:
|
||||
for selector in LOGIN_SELECTORS["captcha_input"]:
|
||||
element = page.locator(selector)
|
||||
if await element.count() > 0 and await element.is_visible():
|
||||
return True
|
||||
|
||||
for selector in LOGIN_SELECTORS["captcha_image"]:
|
||||
element = page.locator(selector)
|
||||
if await element.count() > 0 and await element.is_visible():
|
||||
return True
|
||||
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def _find_element(page: Any, selectors: list) -> Optional[Any]:
|
||||
"""根据选择器列表查找元素。"""
|
||||
for selector in selectors:
|
||||
try:
|
||||
element = page.locator(selector)
|
||||
if await element.count() > 0 and await element.is_visible():
|
||||
return element
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
248
scripts/service/rpa_invoice_service.py
Normal file
248
scripts/service/rpa_invoice_service.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""RPA发票数据服务:处理RPA提取的发票数据存储和查询。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from db.connection import get_conn
|
||||
from db.display_metadata import upsert_table_metadata, seed_column_metadata_batch
|
||||
from db.rpa_invoice_schema import RPA_INVOICES_DDL, RPA_INVOICES_INDEX, RPA_INVOICES_COLUMNS
|
||||
from util.constants import LOG_LOGGER_NAME
|
||||
from util.logging_config import get_skill_logger
|
||||
|
||||
|
||||
def init_rpa_invoice_table() -> None:
|
||||
"""初始化RPA发票数据表。"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 创建表
|
||||
cursor.execute(RPA_INVOICES_DDL)
|
||||
cursor.execute(RPA_INVOICES_INDEX)
|
||||
|
||||
# 添加表元数据
|
||||
upsert_table_metadata(
|
||||
cursor,
|
||||
table_name="rpa_invoices",
|
||||
display_name="RPA发票提取",
|
||||
description="通过RPA从网站提取的发票数据",
|
||||
grid_help="查看RPA提取的发票数据,可按批次ID筛选",
|
||||
visible=1,
|
||||
readonly=1,
|
||||
default_sort_json='[{"column":"id","direction":"desc"}]',
|
||||
default_visible_columns_json='["id","batch_id","extraction_time","username","status","created_at"]',
|
||||
)
|
||||
|
||||
# 添加列元数据
|
||||
seed_column_metadata_batch(
|
||||
cursor,
|
||||
table_name="rpa_invoices",
|
||||
columns=RPA_INVOICES_COLUMNS,
|
||||
valid_column_names=[str(c["column_name"]) for c in RPA_INVOICES_COLUMNS],
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger = get_skill_logger()
|
||||
logger.error("init_rpa_invoice_table_failed error=%s", str(e))
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def save_rpa_invoice_data(
|
||||
batch_id: str,
|
||||
source_url: str,
|
||||
invoice_data: Dict[str, Any],
|
||||
username: str = "",
|
||||
status: str = "extracted",
|
||||
error_message: str = "",
|
||||
) -> int:
|
||||
"""保存RPA提取的发票数据。
|
||||
|
||||
Args:
|
||||
batch_id: 批次ID
|
||||
source_url: 来源网址
|
||||
invoice_data: 发票数据
|
||||
username: 操作用户
|
||||
status: 状态
|
||||
error_message: 错误信息
|
||||
|
||||
Returns:
|
||||
保存的记录ID
|
||||
"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 将发票数据转换为JSON字符串
|
||||
invoice_data_json = json.dumps(invoice_data, ensure_ascii=False)
|
||||
|
||||
# 插入数据
|
||||
cursor.execute("""
|
||||
INSERT INTO rpa_invoices (
|
||||
batch_id, source_url, username, invoice_data_json, status, error_message
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", (batch_id, source_url, username, invoice_data_json, status, error_message))
|
||||
|
||||
record_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
|
||||
logger = get_skill_logger()
|
||||
logger.info(
|
||||
"rpa_invoice_saved record_id=%s batch_id=%s invoice_count=%s",
|
||||
record_id,
|
||||
batch_id,
|
||||
invoice_data.get("total_count", 0),
|
||||
)
|
||||
|
||||
return record_id
|
||||
|
||||
except Exception as e:
|
||||
logger = get_skill_logger()
|
||||
logger.error("save_rpa_invoice_data_failed error=%s", str(e))
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_rpa_invoice_data(record_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""获取RPA提取的发票数据。"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM rpa_invoices WHERE id = ?", (record_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
# 转换为字典
|
||||
columns = [desc[0] for desc in cursor.description]
|
||||
data = dict(zip(columns, row))
|
||||
|
||||
# 解析JSON数据
|
||||
if "invoice_data_json" in data and data["invoice_data_json"]:
|
||||
try:
|
||||
data["invoice_data"] = json.loads(data["invoice_data_json"])
|
||||
except json.JSONDecodeError:
|
||||
data["invoice_data"] = {}
|
||||
|
||||
return data
|
||||
|
||||
except Exception as e:
|
||||
logger = get_skill_logger()
|
||||
logger.error("get_rpa_invoice_data_failed error=%s", str(e))
|
||||
return None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def list_rpa_invoices(
|
||||
limit: int = 20,
|
||||
batch_id: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""查询RPA提取的发票数据列表。"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 构建查询条件
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
if batch_id:
|
||||
conditions.append("batch_id = ?")
|
||||
params.append(batch_id)
|
||||
|
||||
if username:
|
||||
conditions.append("username = ?")
|
||||
params.append(username)
|
||||
|
||||
if status:
|
||||
conditions.append("status = ?")
|
||||
params.append(status)
|
||||
|
||||
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
||||
|
||||
# 执行查询
|
||||
query = f"""
|
||||
SELECT id, batch_id, source_url, extraction_time, username, status, error_message, created_at
|
||||
FROM rpa_invoices
|
||||
WHERE {where_clause}
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
params.append(limit)
|
||||
|
||||
cursor.execute(query, params)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
# 转换为字典列表
|
||||
columns = [desc[0] for desc in cursor.description]
|
||||
result = [dict(zip(columns, row)) for row in rows]
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger = get_skill_logger()
|
||||
logger.error("list_rpa_invoices_failed error=%s", str(e))
|
||||
return []
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_rpa_invoice_stats() -> Dict[str, Any]:
|
||||
"""获取RPA提取统计信息。"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 总记录数
|
||||
cursor.execute("SELECT COUNT(*) FROM rpa_invoices")
|
||||
total_count = cursor.fetchone()[0]
|
||||
|
||||
# 按状态统计
|
||||
cursor.execute("""
|
||||
SELECT status, COUNT(*) as count
|
||||
FROM rpa_invoices
|
||||
GROUP BY status
|
||||
""")
|
||||
status_stats = {row[0]: row[1] for row in cursor.fetchall()}
|
||||
|
||||
# 最近提取记录
|
||||
cursor.execute("""
|
||||
SELECT id, batch_id, extraction_time, username, status
|
||||
FROM rpa_invoices
|
||||
ORDER BY id DESC
|
||||
LIMIT 5
|
||||
""")
|
||||
recent_extractions = [
|
||||
{
|
||||
"id": row[0],
|
||||
"batch_id": row[1],
|
||||
"extraction_time": row[2],
|
||||
"username": row[3],
|
||||
"status": row[4],
|
||||
}
|
||||
for row in cursor.fetchall()
|
||||
]
|
||||
|
||||
return {
|
||||
"total_count": total_count,
|
||||
"status_stats": status_stats,
|
||||
"recent_extractions": recent_extractions,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger = get_skill_logger()
|
||||
logger.error("get_rpa_invoice_stats_failed error=%s", str(e))
|
||||
return {"error": str(e)}
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -697,3 +697,292 @@ def cmd_stats() -> int:
|
||||
log.exception("task_failed task_type=stats")
|
||||
print("ERROR:QUERY_FAILED 统计查询异常,详见统一日志")
|
||||
return 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RPA 命令:发票信息提取
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cmd_extract_invoices(
|
||||
username: str,
|
||||
password: str,
|
||||
profile_dir: str,
|
||||
headless: bool = False,
|
||||
detail_mode: bool = False,
|
||||
) -> int:
|
||||
"""从网站提取发票信息。"""
|
||||
log = _get_task_logger()
|
||||
task_type = "extract_invoices"
|
||||
|
||||
with job_context(skill=SKILL_SLUG):
|
||||
log.info(
|
||||
"task_start task_type=%s username=%s detail_mode=%s",
|
||||
task_type,
|
||||
username[:3] + "***",
|
||||
detail_mode,
|
||||
)
|
||||
emit("开始提取发票信息", skill=SKILL_SLUG, stage="rpa_start")
|
||||
|
||||
try:
|
||||
ok, reason = check_entitlement(SKILL_SLUG)
|
||||
if not ok:
|
||||
log.warning(
|
||||
"entitlement_failed task_type=%s stage=auth error_code=ENTITLEMENT_DENIED",
|
||||
task_type,
|
||||
)
|
||||
emit("鉴权失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="auth")
|
||||
finish(
|
||||
status="failed",
|
||||
message=reason,
|
||||
skill=SKILL_SLUG,
|
||||
error_code="ENTITLEMENT_DENIED",
|
||||
stage="auth",
|
||||
)
|
||||
print(f"ERROR:ENTITLEMENT_DENIED {reason}")
|
||||
return 1
|
||||
|
||||
log.info("entitlement_ok task_type=%s stage=auth", task_type)
|
||||
|
||||
# 导入RPA模块
|
||||
from service.rpa import run_invoice_rpa_with_video
|
||||
|
||||
# 运行RPA
|
||||
result = asyncio.run(
|
||||
run_invoice_rpa_with_video(
|
||||
username=username,
|
||||
password=password,
|
||||
profile_dir=profile_dir,
|
||||
skill_slug=SKILL_SLUG,
|
||||
skill_data_dir=get_skill_root(),
|
||||
headless=headless,
|
||||
detail_mode=detail_mode,
|
||||
title="开始提取发票信息",
|
||||
closing_title="发票信息提取完成",
|
||||
)
|
||||
)
|
||||
|
||||
if result.success:
|
||||
invoice_count = result.data.get("total_count", 0)
|
||||
log.info(
|
||||
"rpa_success task_type=%s invoice_count=%s stage=extraction",
|
||||
task_type,
|
||||
invoice_count,
|
||||
)
|
||||
emit(f"成功提取 {invoice_count} 张发票信息", skill=SKILL_SLUG, stage="rpa_complete")
|
||||
|
||||
# 保存到数据库
|
||||
try:
|
||||
from service.rpa_invoice_service import save_rpa_invoice_data
|
||||
|
||||
batch_id = uuid.uuid4().hex[:12]
|
||||
record_id = save_rpa_invoice_data(
|
||||
batch_id=batch_id,
|
||||
source_url="https://sandbox.jc2009.com/industries/campus/smartcampus/app/finance/invoice-precheck",
|
||||
invoice_data=result.data,
|
||||
username=username,
|
||||
status="extracted",
|
||||
)
|
||||
|
||||
log.info(
|
||||
"rpa_data_saved record_id=%s batch_id=%s",
|
||||
record_id,
|
||||
batch_id,
|
||||
)
|
||||
|
||||
# 添加记录ID到结果中
|
||||
result.data["record_id"] = record_id
|
||||
result.data["batch_id"] = batch_id
|
||||
|
||||
except Exception as save_error:
|
||||
log.warning("rpa_data_save_failed error=%s", str(save_error))
|
||||
# 即使保存失败,也继续输出结果
|
||||
|
||||
# 输出结果
|
||||
_print_json(result.data)
|
||||
|
||||
finish(
|
||||
status="success",
|
||||
message=f"成功提取 {invoice_count} 张发票信息",
|
||||
skill=SKILL_SLUG,
|
||||
stage="extraction",
|
||||
)
|
||||
return 0
|
||||
else:
|
||||
log.warning(
|
||||
"rpa_failed task_type=%s error=%s stage=%s",
|
||||
task_type,
|
||||
result.error,
|
||||
result.stage,
|
||||
)
|
||||
emit("发票信息提取失败", type="warn", skill=SKILL_SLUG, stage="rpa_failed")
|
||||
|
||||
finish(
|
||||
status="failed",
|
||||
message=result.error or "发票信息提取失败",
|
||||
skill=SKILL_SLUG,
|
||||
error_code="RPA_FAILED",
|
||||
stage=result.stage or "unknown",
|
||||
)
|
||||
print(f"ERROR:RPA_FAILED {result.error}")
|
||||
return 1
|
||||
|
||||
except JobStopped:
|
||||
raise
|
||||
except Exception:
|
||||
log.exception(
|
||||
"task_failed task_type=%s",
|
||||
task_type,
|
||||
)
|
||||
emit("任务失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="rpa_error")
|
||||
finish(
|
||||
status="failed",
|
||||
message="发票信息提取异常,详见统一日志",
|
||||
skill=SKILL_SLUG,
|
||||
stage="rpa_error",
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_list_rpa_invoices(
|
||||
limit: int = 20,
|
||||
batch_id: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
) -> int:
|
||||
"""查询RPA提取的发票数据。"""
|
||||
log = _get_task_logger()
|
||||
task_type = "list_rpa_invoices"
|
||||
|
||||
with job_context(skill=SKILL_SLUG):
|
||||
log.info(
|
||||
"task_start task_type=%s limit=%s batch_id=%s username=%s status=%s",
|
||||
task_type,
|
||||
limit,
|
||||
batch_id,
|
||||
username,
|
||||
status,
|
||||
)
|
||||
|
||||
try:
|
||||
from service.rpa_invoice_service import list_rpa_invoices
|
||||
|
||||
records = list_rpa_invoices(
|
||||
limit=limit,
|
||||
batch_id=batch_id,
|
||||
username=username,
|
||||
status=status,
|
||||
)
|
||||
|
||||
_print_json({
|
||||
"ok": True,
|
||||
"count": len(records),
|
||||
"records": records,
|
||||
})
|
||||
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
log.exception("task_failed task_type=%s", task_type)
|
||||
print(f"ERROR:QUERY_FAILED 查询RPA提取数据失败: {str(e)}")
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_show_rpa_invoice(record_id: int) -> int:
|
||||
"""查看RPA提取的发票数据详情。"""
|
||||
log = _get_task_logger()
|
||||
task_type = "show_rpa_invoice"
|
||||
|
||||
with job_context(skill=SKILL_SLUG):
|
||||
log.info("task_start task_type=%s record_id=%s", task_type, record_id)
|
||||
|
||||
try:
|
||||
from service.rpa_invoice_service import get_rpa_invoice_data
|
||||
|
||||
record = get_rpa_invoice_data(record_id)
|
||||
if not record:
|
||||
print(f"ERROR:RECORD_NOT_FOUND 未找到记录 ID={record_id}")
|
||||
return 1
|
||||
|
||||
_print_json(record)
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
log.exception("task_failed task_type=%s", task_type)
|
||||
print(f"ERROR:QUERY_FAILED 查询RPA发票详情失败: {str(e)}")
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_rpa_invoice_stats() -> int:
|
||||
"""获取RPA提取统计信息。"""
|
||||
log = _get_task_logger()
|
||||
task_type = "rpa_invoice_stats"
|
||||
|
||||
with job_context(skill=SKILL_SLUG):
|
||||
log.info("task_start task_type=%s", task_type)
|
||||
|
||||
try:
|
||||
from service.rpa_invoice_service import get_rpa_invoice_stats
|
||||
|
||||
stats = get_rpa_invoice_stats()
|
||||
_print_json(stats)
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
log.exception("task_failed task_type=%s", task_type)
|
||||
print(f"ERROR:QUERY_FAILED 获取RPA统计失败: {str(e)}")
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_test_rpa_connection(
|
||||
username: str,
|
||||
password: str,
|
||||
profile_dir: str,
|
||||
) -> int:
|
||||
"""测试RPA连接和登录。"""
|
||||
log = _get_task_logger()
|
||||
task_type = "test_rpa_connection"
|
||||
|
||||
with job_context(skill=SKILL_SLUG):
|
||||
log.info(
|
||||
"task_start task_type=%s username=%s",
|
||||
task_type,
|
||||
username[:3] + "***",
|
||||
)
|
||||
emit("测试RPA连接", skill=SKILL_SLUG, stage="test_start")
|
||||
|
||||
try:
|
||||
# 导入RPA模块
|
||||
from service.rpa import close_browser_context, start_browser_session
|
||||
from service.rpa.login_handler import login_if_needed
|
||||
|
||||
# 启动浏览器
|
||||
pw, context, page = asyncio.run(
|
||||
start_browser_session(profile_dir, headless=False)
|
||||
)
|
||||
|
||||
# 测试登录
|
||||
login_success = asyncio.run(
|
||||
login_if_needed(page, username, password, wait_sec=30)
|
||||
)
|
||||
|
||||
# 关闭浏览器
|
||||
asyncio.run(close_browser_context(pw, context))
|
||||
|
||||
if login_success:
|
||||
log.info("rpa_connection_test_success task_type=%s", task_type)
|
||||
emit("RPA连接测试成功", skill=SKILL_SLUG, stage="test_success")
|
||||
_print_json({"success": True, "message": "连接和登录测试成功"})
|
||||
finish(status="success", message="RPA连接测试成功", skill=SKILL_SLUG, stage="test")
|
||||
return 0
|
||||
else:
|
||||
log.warning("rpa_connection_test_failed task_type=%s", task_type)
|
||||
emit("RPA连接测试失败", type="warn", skill=SKILL_SLUG, stage="test_failed")
|
||||
_print_json({"success": False, "message": "登录失败"})
|
||||
finish(status="failed", message="登录失败", skill=SKILL_SLUG, stage="test")
|
||||
return 1
|
||||
|
||||
except Exception as e:
|
||||
log.exception("task_failed task_type=%s", task_type)
|
||||
emit("RPA连接测试异常", type="warn", skill=SKILL_SLUG, stage="test_error")
|
||||
finish(status="failed", message=f"测试异常: {str(e)}", skill=SKILL_SLUG, stage="test")
|
||||
return 1
|
||||
|
||||
Reference in New Issue
Block a user