chore: auto release commit (2026-09-14 22:22:49)
All checks were successful
技能自动化发布 / release (push) Successful in 7s

This commit is contained in:
chenfuqiang
2026-09-14 22:22:49 +08:00
parent 6998fb3bd7
commit 3a80db8a7e
155 changed files with 1365 additions and 21023 deletions

View File

@@ -1,8 +1,16 @@
"""发票RPA主流程:协调登录、导航、提取等操作。"""
"""发票RPA主流程:协调登录、导航、提取等操作。
完整流程:
1. 启动浏览器
2. 登录 sandbox.jc2009.com(手机号+密码)
3. 找到"智慧校园"并点击进入
4. 登录智慧校园(默认 demo 账号)
5. 导航到票据合规预审页面
6. 提取发票数据
"""
from __future__ import annotations
import json
import logging
import uuid
from typing import Any, Dict, Optional
@@ -11,15 +19,28 @@ 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
from .invoice_extractor import extract_form_fields, extract_invoice_list, format_invoice_data
from .login_handler import (
enter_smart_campus,
login_sandbox,
login_smart_campus,
navigate_to_invoice_precheck,
)
logger = logging.getLogger(__name__)
def _get_active_page(context: Any) -> Any:
"""获取 context 中最新的活跃页面(处理新 tab 场景)。"""
pages = context.pages
if pages:
return pages[-1]
raise RuntimeError("ERROR:NO_PAGE 浏览器没有可用页面")
class InvoiceRpaResult:
"""RPA执行结果。"""
def __init__(
self,
success: bool,
@@ -31,7 +52,7 @@ class InvoiceRpaResult:
self.data = data or {}
self.error = error
self.stage = stage
def to_dict(self) -> Dict[str, Any]:
return {
"success": self.success,
@@ -41,70 +62,52 @@ class InvoiceRpaResult:
}
@rpa_step("启动浏览器并登录")
async def _start_and_login(
profile_dir: str,
username: str,
password: str,
headless: bool = False,
) -> tuple[Any, Any, Any, bool]:
"""启动浏览器并登录。"""
@rpa_step("启动浏览器")
async def _start_browser(profile_dir: str, headless: bool = False) -> tuple[Any, Any, Any]:
"""启动浏览器会话。"""
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
return pw, context, page
@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("登录 sandbox 门户")
async def _login_sandbox_portal(page: Any, phone: str, password: str) -> bool:
"""登录 sandbox.jc2009.com 门户。"""
return await login_sandbox(page, phone, password)
@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)}
@rpa_step("进入智慧校园")
async def _enter_campus(page: Any) -> bool:
"""在平台首页找到并点击智慧校园。"""
return await enter_smart_campus(page)
@rpa_step("登录智慧校园")
async def _login_campus(page: Any) -> bool:
"""登录智慧校园系统。"""
return await login_smart_campus(page)
@rpa_step("导航到票据合规预审")
async def _navigate_to_invoice(page: Any) -> bool:
"""导航到票据合规预审页面。"""
return await navigate_to_invoice_precheck(page)
@rpa_step("提取发票数据")
async def _extract_data(page: Any) -> Dict[str, Any]:
"""提取发票列表和表单字段。"""
# 提取表单字段
form_fields = await extract_form_fields(page)
logger.info("form_fields_extracted keys=%s", list(form_fields.keys()))
# 提取发票列表
invoices = await extract_invoice_list(page)
logger.info("invoices_extracted count=%s", len(invoices))
# 组合结果
result = format_invoice_data(invoices)
result.update(form_fields)
return result
async def run_invoice_rpa(
@@ -117,69 +120,107 @@ async def run_invoice_rpa(
batch_id: str = "",
) -> InvoiceRpaResult:
"""运行发票RPA主流程。
Args:
username: 用户名
password: 密码
username: 手机号(sandbox 门户登录)
password: 密码(sandbox 门户登录)
profile_dir: 浏览器配置目录
headless: 是否无头模式
detail_mode: 是否提取详细信息
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] + "***", # 脱敏
"invoice_rpa_start username=%s*** batch_id=%s detail_mode=%s",
(username or "")[: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):
# 步骤 1:启动浏览器
pw, context, page = await _start_browser(profile_dir, headless)
# 步骤 2:登录 sandbox 门户
sandbox_ok = await _login_sandbox_portal(page, username, password)
if not sandbox_ok:
return InvoiceRpaResult(
success=False,
error="导航到发票页面失败",
error="登录 sandbox 门户失败",
stage="sandbox_login",
)
# 步骤 3:进入智慧校园(可能打开新 tab,需要刷新 page)
campus_ok = await _enter_campus(page)
if not campus_ok:
return InvoiceRpaResult(
success=False,
error="进入智慧校园失败",
stage="enter_campus",
)
# 刷新 page 引用(处理新 tab 场景)
page = _get_active_page(context)
logger.info("page_refreshed_after_enter_campus url=%s", page.url)
# 步骤 4:登录智慧校园
login_ok = await _login_campus(page)
if not login_ok:
return InvoiceRpaResult(
success=False,
error="登录智慧校园失败",
stage="campus_login",
)
# 刷新 page 引用
page = _get_active_page(context)
logger.info("page_refreshed_after_campus_login url=%s", page.url)
# 步骤 5:导航到票据合规预审
nav_ok = await _navigate_to_invoice(page)
if not nav_ok:
return InvoiceRpaResult(
success=False,
error="导航到票据合规预审页面失败",
stage="navigation",
)
# 提取发票信息
result_data = await _extract_invoices(page, detail_mode)
# 刷新 page 引用
page = _get_active_page(context)
logger.info("page_refreshed_after_navigate url=%s", page.url)
# 步骤 6:提取发票数据
result_data = await _extract_data(page)
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(
@@ -187,7 +228,7 @@ async def run_invoice_rpa(
error=str(e),
stage="unknown",
)
finally:
# 关闭浏览器
if pw and context:
@@ -207,7 +248,7 @@ async def run_invoice_rpa_with_video(
) -> InvoiceRpaResult:
"""带录屏的发票RPA主流程。"""
batch_id = uuid.uuid4().hex[:12]
async with RpaVideoSession(
skill_slug=skill_slug,
skill_data_dir=skill_data_dir,
@@ -216,7 +257,7 @@ async def run_invoice_rpa_with_video(
closing_title=closing_title,
) as video:
video.add_step("准备启动浏览器")
# 运行RPA
result = await run_invoice_rpa(
username=username,
@@ -227,10 +268,10 @@ async def run_invoice_rpa_with_video(
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
return result