chore: auto release commit (2026-09-14 22:22:49)
All checks were successful
技能自动化发布 / release (push) Successful in 7s
All checks were successful
技能自动化发布 / release (push) Successful in 7s
This commit is contained in:
@@ -1,16 +1,36 @@
|
||||
"""RPA模块:提供发票信息提取的自动化功能。"""
|
||||
"""RPA模块:提供发票信息提取的自动化功能。
|
||||
|
||||
from .browser_session import close_browser_context, start_browser_session
|
||||
from .invoice_extractor import extract_invoice_detail, extract_invoice_list, format_invoice_data
|
||||
完整流程:
|
||||
1. 登录 sandbox.jc2009.com(手机号+密码)
|
||||
2. 找到"智慧校园"并点击进入
|
||||
3. 登录智慧校园(默认 demo 账号)
|
||||
4. 导航到票据合规预审页面
|
||||
5. 提取发票数据
|
||||
"""
|
||||
|
||||
from .browser_session import close_browser_context, find_browser_executable, start_browser_session
|
||||
from .invoice_extractor import extract_form_fields, 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
|
||||
from .login_handler import (
|
||||
enter_smart_campus,
|
||||
login_if_needed,
|
||||
login_sandbox,
|
||||
login_smart_campus,
|
||||
navigate_to_invoice_precheck,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"start_browser_session",
|
||||
"close_browser_context",
|
||||
"find_browser_executable",
|
||||
"login_if_needed",
|
||||
"login_sandbox",
|
||||
"enter_smart_campus",
|
||||
"login_smart_campus",
|
||||
"navigate_to_invoice_precheck",
|
||||
"extract_invoice_list",
|
||||
"extract_invoice_detail",
|
||||
"extract_form_fields",
|
||||
"format_invoice_data",
|
||||
"run_invoice_rpa",
|
||||
"run_invoice_rpa_with_video",
|
||||
|
||||
@@ -1,67 +1,147 @@
|
||||
"""浏览器会话管理:基于skill-template的real_browser_rpa示例。"""
|
||||
"""浏览器会话管理:基于 skill-template 的 simulator_browser_rpa 示例。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
from typing import Any, Optional, Tuple
|
||||
from typing import Any, List, 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"
|
||||
DEFAULT_PAGE_TIMEOUT_MS = 15_000
|
||||
|
||||
CHROME_LAUNCH_ARGS: List[str] = [
|
||||
"--start-maximized",
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
]
|
||||
|
||||
STEALTH_INIT_SCRIPT = """
|
||||
(() => {
|
||||
try { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); } catch (e) {}
|
||||
})();
|
||||
"""
|
||||
|
||||
|
||||
def chrome_launch_args() -> List[str]:
|
||||
return list(CHROME_LAUNCH_ARGS)
|
||||
|
||||
|
||||
def _headless_from_env() -> bool:
|
||||
v = (os.getenv("OPENCLAW_BROWSER_HEADLESS") or "0").strip().lower()
|
||||
return v in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def find_browser_executable() -> Optional[str]:
|
||||
"""查找系统 Chrome 或 Edge 浏览器。"""
|
||||
try:
|
||||
from jiangchang_skill_core.runtime_env import find_chrome_executable as _find
|
||||
chrome = _find()
|
||||
if chrome and os.path.isfile(chrome):
|
||||
return chrome
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
candidates = [
|
||||
os.path.expandvars(r"%ProgramFiles%\Google\Chrome\Application\chrome.exe"),
|
||||
os.path.expandvars(r"%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe"),
|
||||
os.path.expandvars(r"%ProgramFiles%\Microsoft\Edge\Application\msedge.exe"),
|
||||
os.path.expandvars(r"%ProgramFiles(x86)%\Microsoft\Edge\Application\msedge.exe"),
|
||||
]
|
||||
for path in candidates:
|
||||
if path and os.path.isfile(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
# 向后兼容别名
|
||||
find_edge_executable = find_browser_executable
|
||||
|
||||
|
||||
def _clear_node_options() -> None:
|
||||
os.environ.pop("NODE_OPTIONS", None)
|
||||
|
||||
|
||||
async def start_browser_session(
|
||||
profile_dir: str,
|
||||
headless: bool = False,
|
||||
) -> Tuple[Any, Any, Any]:
|
||||
"""启动浏览器会话。
|
||||
|
||||
"""启动持久化 Chrome/Edge 浏览器会话。
|
||||
|
||||
URL 不在此文件打开;由 invoice_rpa.py 负责 page.goto。
|
||||
|
||||
Args:
|
||||
profile_dir: 浏览器配置目录
|
||||
headless: 是否无头模式(生产必须为False)
|
||||
|
||||
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",
|
||||
],
|
||||
|
||||
# 确保 profile 目录存在
|
||||
os.makedirs(profile_dir, exist_ok=True)
|
||||
|
||||
_clear_node_options()
|
||||
hl = headless if headless is not None else _headless_from_env()
|
||||
chrome = find_browser_executable()
|
||||
if not chrome:
|
||||
raise RuntimeError(
|
||||
"ERROR:MISSING_BROWSER 未检测到 Chrome 或 Edge 浏览器,请先安装系统浏览器后重试。"
|
||||
)
|
||||
|
||||
# 创建新页面
|
||||
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
|
||||
|
||||
|
||||
args = chrome_launch_args()
|
||||
logger.info(
|
||||
"browser_launch chrome=%s profile_dir=%s args=%s headless=%s",
|
||||
chrome,
|
||||
profile_dir,
|
||||
args,
|
||||
hl,
|
||||
)
|
||||
|
||||
try:
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
pw = await async_playwright().start()
|
||||
context = None
|
||||
try:
|
||||
context = await pw.chromium.launch_persistent_context(
|
||||
user_data_dir=profile_dir,
|
||||
headless=hl,
|
||||
executable_path=chrome,
|
||||
locale="zh-CN",
|
||||
no_viewport=True,
|
||||
args=args,
|
||||
ignore_default_args=["--enable-automation"],
|
||||
)
|
||||
await context.add_init_script(STEALTH_INIT_SCRIPT)
|
||||
|
||||
page = context.pages[0] if context.pages else await context.new_page()
|
||||
page.set_default_timeout(DEFAULT_PAGE_TIMEOUT_MS)
|
||||
logger.info("browser_started profile_dir=%s", profile_dir)
|
||||
return pw, context, page
|
||||
except Exception:
|
||||
if context is not None:
|
||||
try:
|
||||
await context.close()
|
||||
except Exception:
|
||||
pass
|
||||
await pw.stop()
|
||||
raise
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("browser_start_failed error=%s", str(e))
|
||||
raise RuntimeError(f"ERROR:MISSING_BROWSER 浏览器启动失败: {str(e)}")
|
||||
@@ -70,22 +150,24 @@ async def start_browser_session(
|
||||
async def close_browser_context(pw: Any, context: Any) -> None:
|
||||
"""安全关闭浏览器上下文。"""
|
||||
try:
|
||||
if context:
|
||||
if context is not None:
|
||||
await context.close()
|
||||
if pw:
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if pw is not None:
|
||||
await pw.stop()
|
||||
except Exception as e:
|
||||
logger.warning("browser_close_error error=%s", str(e))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
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":
|
||||
ready_state = await page.evaluate("document.readyState")
|
||||
if ready_state == "complete":
|
||||
return True
|
||||
# 检查是否有加载指示器
|
||||
loading_selectors = [".loading", ".spinner", "[data-loading]"]
|
||||
for sel in loading_selectors:
|
||||
if await page.locator(sel).count() > 0:
|
||||
@@ -93,9 +175,16 @@ async def wait_for_page_ready(page: Any, timeout_sec: float = 30.0) -> bool:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
result = await poll_until(_check_ready, timeout_sec=timeout_sec, interval_sec=0.5)
|
||||
return result.ok
|
||||
|
||||
try:
|
||||
from jiangchang_skill_core.rpa import poll_until
|
||||
result = await poll_until(_check_ready, timeout_sec=timeout_sec, interval_sec=0.5)
|
||||
return result.ok
|
||||
except ImportError:
|
||||
# fallback: 简单等待
|
||||
import asyncio
|
||||
await asyncio.sleep(min(timeout_sec, 3.0))
|
||||
return True
|
||||
|
||||
|
||||
async def random_delay(min_sec: float = 1.0, max_sec: float = 3.0) -> None:
|
||||
|
||||
@@ -1,343 +1,264 @@
|
||||
"""发票信息提取器:从网站提取发票信息。"""
|
||||
"""发票信息提取器:从票据合规预审页面提取发票数据。
|
||||
|
||||
根据 sandbox.jc2009.com/industries/campus/smartcampus/app/finance/invoice-precheck 页面 DOM 结构:
|
||||
- 报销事由:文本输入框
|
||||
- 预算项目:下拉选择框
|
||||
- 差旅审批单:下拉选择框
|
||||
- 选择发票:列表,每项包含 checkbox、发票号、金额、类型、公司名、日期、购方税号
|
||||
"""
|
||||
|
||||
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页面对象
|
||||
|
||||
"""从票据合规预审页面提取发票列表。
|
||||
|
||||
页面中发票以列表项形式展示,每项包含:
|
||||
- Checkbox(勾选状态)
|
||||
- 发票号(8位数字)
|
||||
- 金额(¥xxx)
|
||||
- 类型(transport/lodging 等)
|
||||
- 公司名
|
||||
- 日期
|
||||
- 购方税号
|
||||
- 验真状态("未验真" 标记)
|
||||
|
||||
Returns:
|
||||
发票信息列表
|
||||
发票信息字典列表
|
||||
"""
|
||||
invoices = []
|
||||
|
||||
invoices: List[Dict[str, Any]] = []
|
||||
|
||||
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):
|
||||
# 等待发票区域加载(checkbox 出现即可开始提取)
|
||||
try:
|
||||
await page.locator('input[type="checkbox"]').first.wait_for(state="attached", timeout=10_000)
|
||||
except Exception:
|
||||
pass # 超时也继续尝试提取
|
||||
|
||||
# 方式一:通过 checkbox 定位发票项(最可靠)
|
||||
checkboxes = page.locator('input[type="checkbox"]')
|
||||
checkbox_count = await checkboxes.count()
|
||||
logger.info("found_checkboxes count=%s", checkbox_count)
|
||||
|
||||
if checkbox_count > 0:
|
||||
# 每个 checkbox 对应一个发票项,向上找到父容器
|
||||
for i in range(checkbox_count):
|
||||
try:
|
||||
invoice_data = await _extract_invoice_from_row(page, row, i)
|
||||
if invoice_data:
|
||||
invoices.append(invoice_data)
|
||||
checkbox = checkboxes.nth(i)
|
||||
# 获取 checkbox 所在的父级容器(卡片或行)
|
||||
parent = checkbox.locator("xpath=ancestor::div[contains(@class,'card') or contains(@class,'item') or contains(@class,'row') or contains(@class,'invoice')]").first
|
||||
if await parent.count() == 0:
|
||||
# fallback: 向上找 3 层
|
||||
parent = checkbox.locator("xpath=ancestor::div[3]").first
|
||||
|
||||
text = await parent.inner_text() if await parent.count() > 0 else ""
|
||||
is_checked = await checkbox.is_checked()
|
||||
|
||||
invoice = _parse_invoice_text(text)
|
||||
if invoice:
|
||||
invoice["selected"] = is_checked
|
||||
invoices.append(invoice)
|
||||
except Exception as e:
|
||||
logger.warning("extract_row_failed page_row=%s error=%s", i, str(e))
|
||||
logger.warning("extract_checkbox_item_failed index=%s error=%s", i, str(e))
|
||||
continue
|
||||
|
||||
has_next = await _has_next_page(page)
|
||||
|
||||
|
||||
# 方式二:如果方式一没提取到,通过文本匹配
|
||||
if not invoices:
|
||||
logger.info("fallback_to_text_extraction")
|
||||
invoices = await _extract_by_text_pattern(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_by_text_pattern(page: Any) -> List[Dict[str, Any]]:
|
||||
"""通过文本模式匹配提取发票信息(fallback)。"""
|
||||
invoices: List[Dict[str, Any]] = []
|
||||
|
||||
try:
|
||||
# 查找包含发票号(8位数字)的元素
|
||||
invoice_pattern = page.locator("div, tr, li").filter(
|
||||
has_text=re.compile(r"\d{8}.*[¥¥]\s*[\d,]+")
|
||||
)
|
||||
count = await invoice_pattern.count()
|
||||
logger.info("text_pattern_match count=%s", count)
|
||||
|
||||
for i in range(count):
|
||||
try:
|
||||
element = invoice_pattern.nth(i)
|
||||
text = await element.inner_text()
|
||||
invoice = _parse_invoice_text(text)
|
||||
if invoice:
|
||||
# 检查是否被选中(附近有 checkbox)
|
||||
checkbox = element.locator('input[type="checkbox"]')
|
||||
if await checkbox.count() > 0:
|
||||
invoice["selected"] = await checkbox.first.is_checked()
|
||||
else:
|
||||
invoice["selected"] = False
|
||||
invoices.append(invoice)
|
||||
except Exception as e:
|
||||
logger.warning("text_pattern_extract_failed index=%s error=%s", i, str(e))
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("text_pattern_extraction_error error=%s", str(e))
|
||||
|
||||
return invoices
|
||||
|
||||
|
||||
def _parse_invoice_text(text: str) -> Optional[Dict[str, Any]]:
|
||||
"""从发票文本中解析结构化数据。
|
||||
|
||||
典型格式:
|
||||
87654321 · ¥553 · transport
|
||||
中国铁路北京局集团有限公司 · 2026-03-12 · 购方税号 121000004000012345
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return None
|
||||
|
||||
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
|
||||
full_text = " ".join(lines)
|
||||
|
||||
# 提取发票号(8位数字)
|
||||
invoice_no_match = re.search(r"(\d{8})", full_text)
|
||||
invoice_no = invoice_no_match.group(1) if invoice_no_match else ""
|
||||
if not invoice_no:
|
||||
return None
|
||||
|
||||
# 提取金额(¥ 后跟数字)
|
||||
amount_match = re.search(r"[¥¥]\s*([\d,]+\.?\d*)", full_text)
|
||||
amount = 0.0
|
||||
if amount_match:
|
||||
amount_str = amount_match.group(1).replace(",", "")
|
||||
try:
|
||||
amount = float(amount_str)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 提取类型(transport/lodging/meal/other)
|
||||
invoice_type = ""
|
||||
for tp in ["transport", "lodging", "meal", "other"]:
|
||||
if tp in full_text.lower():
|
||||
invoice_type = tp
|
||||
break
|
||||
|
||||
# 提取公司名(中文公司名,包含常见后缀)
|
||||
company_match = re.search(
|
||||
r"([\u4e00-\u9fa5]{2,}(?:公司|集团|酒店|商贸|有限|商店|宾馆|饭店))", full_text
|
||||
)
|
||||
company = company_match.group(1) if company_match else ""
|
||||
|
||||
# 提取日期(YYYY-MM-DD)
|
||||
date_match = re.search(r"(\d{4}-\d{2}-\d{2})", full_text)
|
||||
date_str = date_match.group(1) if date_match else ""
|
||||
|
||||
# 提取购方税号
|
||||
tax_no_match = re.search(r"购方税号\s*(\S+)", full_text)
|
||||
tax_no = tax_no_match.group(1) if tax_no_match else ""
|
||||
if not tax_no:
|
||||
# fallback: 查找 15-20 位字母数字混合税号
|
||||
tax_no_fallback = re.search(r"([0-9A-Z]{15,20})", full_text)
|
||||
if tax_no_fallback:
|
||||
tax_no = tax_no_fallback.group(1)
|
||||
|
||||
# 判断验真状态
|
||||
verified = "未验真" not in full_text
|
||||
|
||||
return {
|
||||
"invoice_no": invoice_no,
|
||||
"amount": amount,
|
||||
"invoice_type": invoice_type,
|
||||
"company": company,
|
||||
"date": date_str,
|
||||
"tax_no": tax_no,
|
||||
"verified": verified,
|
||||
}
|
||||
|
||||
|
||||
async def extract_invoice_detail(page: Any, invoice_index: int = 0) -> Optional[Dict[str, Any]]:
|
||||
"""提取单个发票的详细信息。
|
||||
|
||||
"""提取单个发票的详细信息(点击进入详情页后调用)。
|
||||
|
||||
Args:
|
||||
page: Playwright页面对象
|
||||
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))
|
||||
rows = page.locator("tr").filter(has_text=re.compile(r"\d{6,}"))
|
||||
count = await rows.count()
|
||||
if invoice_index >= count:
|
||||
logger.warning("invoice_index_out_of_range index=%s total=%s", invoice_index, count)
|
||||
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
|
||||
|
||||
|
||||
row = rows.nth(invoice_index)
|
||||
text = await row.inner_text()
|
||||
return _parse_invoice_text(text)
|
||||
|
||||
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_form_fields(page: Any) -> Dict[str, str]:
|
||||
"""提取页面表单字段(报销事由、预算项目、差旅审批单)。"""
|
||||
result: Dict[str, str] = {}
|
||||
|
||||
|
||||
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
|
||||
|
||||
reason_input = page.get_by_label("报销事由")
|
||||
if await reason_input.count() > 0:
|
||||
result["reimbursement_reason"] = (await reason_input.input_value()).strip()
|
||||
except Exception:
|
||||
return []
|
||||
pass
|
||||
|
||||
|
||||
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
|
||||
budget_selects = page.locator("select")
|
||||
select_count = await budget_selects.count()
|
||||
for i in range(select_count):
|
||||
sel = budget_selects.nth(i)
|
||||
text = await sel.inner_text()
|
||||
if "GDST" in text or "乡村振兴" in text or "预算" in text:
|
||||
# 获取选中的 option
|
||||
selected = sel.locator("option:checked")
|
||||
if await selected.count() > 0:
|
||||
result["budget_project"] = (await selected.first.inner_text()).strip()
|
||||
else:
|
||||
result["budget_project"] = text.strip()[:100]
|
||||
break
|
||||
except Exception:
|
||||
return False
|
||||
pass
|
||||
|
||||
|
||||
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
|
||||
travel_selects = page.locator("select")
|
||||
select_count = await travel_selects.count()
|
||||
for i in range(select_count):
|
||||
sel = travel_selects.nth(i)
|
||||
text = await sel.inner_text()
|
||||
if "CL-" in text or "差旅" in text or "北京" in text:
|
||||
selected = sel.locator("option:checked")
|
||||
if await selected.count() > 0:
|
||||
result["travel_approval"] = (await selected.first.inner_text()).strip()
|
||||
else:
|
||||
result["travel_approval"] = text.strip()[:100]
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
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
|
||||
return result
|
||||
|
||||
|
||||
def format_invoice_data(invoices: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
@@ -346,4 +267,4 @@ def format_invoice_data(invoices: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"total_count": len(invoices),
|
||||
"invoices": invoices,
|
||||
"extraction_complete": True,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,57 +1,34 @@
|
||||
"""登录处理器:处理网站登录流程。"""
|
||||
"""登录处理器:两层登录(sandbox 门户 + 智慧校园)。
|
||||
|
||||
流程:
|
||||
1. 检测 sandbox.jc2009.com 登录页 → 手机号+密码登录
|
||||
2. 平台首页找到"智慧校园" → 点击进入
|
||||
3. 智慧校园师生登录页 → demo 账号登录
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import re
|
||||
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",
|
||||
],
|
||||
}
|
||||
# ── Sandbox 门户登录配置 ──
|
||||
SANDBOX_LOGIN_PHONE = "15527406659"
|
||||
SANDBOX_LOGIN_PASSWORD = "qwer1234"
|
||||
|
||||
# 登录成功检测
|
||||
LOGIN_SUCCESS_SELECTORS = [
|
||||
".user-info",
|
||||
".welcome",
|
||||
"[data-testid='user-avatar']",
|
||||
"a[href*='logout']",
|
||||
".dashboard",
|
||||
]
|
||||
# ── 智慧校园默认凭据 ──
|
||||
SMART_CAMPUS_DEFAULT_USER = "demo"
|
||||
SMART_CAMPUS_DEFAULT_PASSWORD = "demo123"
|
||||
|
||||
# ── 超时 ──
|
||||
NAVIGATION_TIMEOUT_MS = 30_000
|
||||
POLL_INTERVAL_SEC = 1.0 # 轮询间隔(秒)
|
||||
TYPE_DELAY_MS = 50 # 逐字符输入延迟(毫秒)
|
||||
|
||||
|
||||
async def login_if_needed(
|
||||
@@ -60,149 +37,286 @@ async def login_if_needed(
|
||||
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)
|
||||
"""检查登录状态,如需要则执行登录。"""
|
||||
phone = username or SANDBOX_LOGIN_PHONE
|
||||
pwd = password or SANDBOX_LOGIN_PASSWORD
|
||||
|
||||
|
||||
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))
|
||||
sandbox_ok = await login_sandbox(page, phone, pwd)
|
||||
if not sandbox_ok:
|
||||
return False
|
||||
|
||||
campus_entered = await enter_smart_campus(page)
|
||||
if not campus_entered:
|
||||
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("检测到登录成功")
|
||||
campus_ok = await login_smart_campus(page)
|
||||
return campus_ok
|
||||
|
||||
|
||||
# ── 工具函数 ──
|
||||
|
||||
|
||||
def _is_sandbox_home(page_url: str) -> bool:
|
||||
return "select" in page_url or "/industries" in page_url
|
||||
|
||||
|
||||
async def _type_field(locator, text: str) -> None:
|
||||
"""快速填入字段:fill 直接设值(比 keyboard.type 快 10 倍)。"""
|
||||
await locator.click()
|
||||
await locator.fill(text)
|
||||
|
||||
|
||||
async def _poll_until(check_fn, timeout_sec: float = 30.0) -> bool:
|
||||
"""快速轮询:每 1 秒检测一次,检测到立刻返回。check_fn 必须是无参 async callable。"""
|
||||
elapsed = 0.0
|
||||
while elapsed < timeout_sec:
|
||||
if await check_fn():
|
||||
return True
|
||||
|
||||
# 检查是否有验证码需要处理
|
||||
captcha_present = await _check_captcha(page)
|
||||
if captcha_present:
|
||||
print("检测到验证码,请人工完成验证...")
|
||||
|
||||
await interruptible_sleep(2.0)
|
||||
|
||||
print("登录超时")
|
||||
await asyncio.sleep(POLL_INTERVAL_SEC)
|
||||
elapsed += POLL_INTERVAL_SEC
|
||||
return False
|
||||
|
||||
|
||||
async def _is_logged_in(page: Any) -> bool:
|
||||
"""检查是否已登录。"""
|
||||
# ── 阶段一:登录 sandbox.jc2009.com ──
|
||||
|
||||
|
||||
async def login_sandbox(page: Any, phone: str, password: str) -> bool:
|
||||
"""登录 sandbox.jc2009.com 门户。已登录则直接返回 True。"""
|
||||
logger.info("sandbox_login_start phone=%s***", phone[:3])
|
||||
|
||||
try:
|
||||
# 检查URL是否包含登录成功标识
|
||||
if "/login" not in page.url and "/index" in page.url:
|
||||
if _is_sandbox_home(page.url) or await page.get_by_role("button", name=re.compile(r"Sign out|退出")).count() > 0 or await page.get_by_text("智慧校园", exact=False).count() > 0:
|
||||
logger.info("sandbox_already_logged_in url=%s", page.url)
|
||||
return True
|
||||
|
||||
# 检查页面元素
|
||||
for selector in LOGIN_SUCCESS_SELECTORS:
|
||||
|
||||
# 找登录表单
|
||||
phone_input = page.get_by_role("textbox", name=re.compile(r"Phone|phone|手机"))
|
||||
if await phone_input.count() == 0:
|
||||
await page.goto("https://sandbox.jc2009.com/", wait_until="domcontentloaded", timeout=NAVIGATION_TIMEOUT_MS)
|
||||
# 用轮询替代固定等待
|
||||
async def _home_check():
|
||||
return _is_sandbox_home(page.url) or await page.get_by_text("智慧校园", exact=False).count() > 0
|
||||
await _poll_until(_home_check, timeout_sec=10.0)
|
||||
if _is_sandbox_home(page.url) or await page.get_by_text("智慧校园", exact=False).count() > 0:
|
||||
logger.info("sandbox_already_logged_in_after_redirect")
|
||||
return True
|
||||
phone_input = page.get_by_role("textbox", name=re.compile(r"Phone|phone|手机"))
|
||||
|
||||
if await phone_input.count() == 0:
|
||||
logger.warning("sandbox_login_page_not_found url=%s", page.url)
|
||||
return False
|
||||
|
||||
logger.info("sandbox_login_page_detected")
|
||||
|
||||
# 输入手机号(fill 直接设值,比 type 快)
|
||||
await _type_field(phone_input, phone)
|
||||
# 输入密码
|
||||
pwd_input = page.get_by_role("textbox", name=re.compile(r"Password|password|密码"))
|
||||
if await pwd_input.count() == 0:
|
||||
pwd_input = page.get_by_label("Password")
|
||||
await _type_field(pwd_input, password)
|
||||
|
||||
# 点击 Sign in
|
||||
sign_in_btn = page.get_by_role("button", name=re.compile(r"Sign in|登录"))
|
||||
await sign_in_btn.click()
|
||||
logger.info("sandbox_login_click_sign_in")
|
||||
|
||||
# 快速轮询等待登录成功
|
||||
async def _login_done():
|
||||
return (
|
||||
_is_sandbox_home(page.url)
|
||||
or await page.get_by_role("button", name=re.compile(r"Sign out|退出")).count() > 0
|
||||
or await page.get_by_text("智慧校园", exact=False).count() > 0
|
||||
)
|
||||
|
||||
if await _poll_until(_login_done, timeout_sec=30.0):
|
||||
logger.info("sandbox_login_success url=%s", page.url)
|
||||
return True
|
||||
|
||||
logger.warning("sandbox_login_timeout")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error("sandbox_login_failed error=%s url=%s", str(e), page.url)
|
||||
return False
|
||||
|
||||
|
||||
# ── 阶段二:进入智慧校园 ──
|
||||
|
||||
|
||||
async def enter_smart_campus(page: Any) -> bool:
|
||||
"""在平台首页找到并点击"智慧校园"卡片。"""
|
||||
logger.info("enter_smart_campus_start url=%s", page.url)
|
||||
|
||||
try:
|
||||
campus_card = page.get_by_text("智慧校园", exact=False)
|
||||
count = await campus_card.count()
|
||||
if count == 0:
|
||||
logger.warning("smart_campus_not_found")
|
||||
return False
|
||||
|
||||
pages_before = len(page.context.pages)
|
||||
|
||||
# 点击卡片
|
||||
clicked = False
|
||||
for i in range(count):
|
||||
try:
|
||||
element = page.locator(selector)
|
||||
if await element.count() > 0 and await element.is_visible():
|
||||
return True
|
||||
el = campus_card.nth(i)
|
||||
parent = el.locator("xpath=ancestor::a[1]")
|
||||
if await parent.count() > 0:
|
||||
await parent.first.click()
|
||||
clicked = True
|
||||
break
|
||||
parent = el.locator("xpath=ancestor::div[contains(@class,'card') or contains(@class,'item') or contains(@class,'clickable')][1]")
|
||||
if await parent.count() > 0:
|
||||
await parent.first.click()
|
||||
clicked = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not clicked:
|
||||
await campus_card.first.click()
|
||||
|
||||
# 快速检测是否有新 tab
|
||||
await asyncio.sleep(1.0)
|
||||
pages_after = len(page.context.pages)
|
||||
active_page = page
|
||||
if pages_after > pages_before:
|
||||
active_page = page.context.pages[-1]
|
||||
logger.info("new_tab url=%s", active_page.url)
|
||||
else:
|
||||
active_page = page.context.pages[-1]
|
||||
|
||||
# 快速轮询等待校园登录页
|
||||
async def _campus_page_ready():
|
||||
p = active_page
|
||||
if await p.get_by_text("师生登录", exact=False).count() > 0:
|
||||
return True
|
||||
if await p.get_by_label("账号").count() > 0:
|
||||
return True
|
||||
if await p.locator('input[type="password"]').count() > 0:
|
||||
return True
|
||||
if await p.get_by_text("工作台", exact=False).count() > 0:
|
||||
return True
|
||||
if await p.get_by_text("华江示范大学", exact=False).count() > 0:
|
||||
return True
|
||||
if "smartcampus" in p.url or "campus" in p.url:
|
||||
return True
|
||||
# 排除还在 sandbox 门户的情况
|
||||
if await p.get_by_text("Password sign-in", exact=False).count() > 0:
|
||||
return False
|
||||
login_btn = p.get_by_role("button", name=re.compile(r"登录"))
|
||||
if await login_btn.count() > 0:
|
||||
return True
|
||||
return False
|
||||
|
||||
if await _poll_until(_campus_page_ready, timeout_sec=30.0):
|
||||
logger.info("smart_campus_page_ready url=%s", active_page.url)
|
||||
return True
|
||||
|
||||
logger.warning("smart_campus_timeout url=%s", active_page.url)
|
||||
return False
|
||||
except Exception:
|
||||
|
||||
except Exception as e:
|
||||
logger.error("enter_smart_campus_failed error=%s url=%s", str(e), page.url)
|
||||
return False
|
||||
|
||||
|
||||
async def _check_captcha(page: Any) -> bool:
|
||||
"""检查是否有验证码。"""
|
||||
# ── 阶段三:登录智慧校园 ──
|
||||
|
||||
|
||||
async def login_smart_campus(page: Any) -> bool:
|
||||
"""登录智慧校园系统。"""
|
||||
logger.info("smart_campus_login_start")
|
||||
|
||||
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
|
||||
|
||||
# 已登录直接返回
|
||||
if await page.get_by_text("工作台", exact=False).count() > 0:
|
||||
logger.info("smart_campus_already_logged_in")
|
||||
return True
|
||||
|
||||
# 填账号(如果为空)
|
||||
account_input = page.get_by_label("账号")
|
||||
if await account_input.count() == 0:
|
||||
account_input = page.locator('input[placeholder*="账号"], input[placeholder*="用户名"]')
|
||||
if await account_input.count() > 0:
|
||||
val = await account_input.input_value()
|
||||
if not val or not val.strip():
|
||||
await _type_field(account_input, SMART_CAMPUS_DEFAULT_USER)
|
||||
|
||||
# 填密码(如果为空)
|
||||
pwd_input = page.get_by_label("密码")
|
||||
if await pwd_input.count() == 0:
|
||||
pwd_input = page.locator('input[type="password"]')
|
||||
if await pwd_input.count() > 0:
|
||||
val = await pwd_input.input_value()
|
||||
if not val or not val.strip():
|
||||
await _type_field(pwd_input, SMART_CAMPUS_DEFAULT_PASSWORD)
|
||||
|
||||
# 点击登录
|
||||
login_btn = page.get_by_role("button", name=re.compile(r"登录|登 录"))
|
||||
if await login_btn.count() == 0:
|
||||
login_btn = page.locator('button:has-text("登录"), button[type="submit"]')
|
||||
await login_btn.first.click()
|
||||
logger.info("smart_campus_login_click")
|
||||
|
||||
# 快速轮询等待进入系统
|
||||
async def _workbench_ready():
|
||||
return await page.get_by_text("工作台", exact=False).count() > 0
|
||||
|
||||
if await _poll_until(_workbench_ready, timeout_sec=20.0):
|
||||
logger.info("smart_campus_login_success")
|
||||
return True
|
||||
|
||||
logger.warning("smart_campus_login_timeout")
|
||||
return False
|
||||
except Exception:
|
||||
|
||||
except Exception as e:
|
||||
logger.error("smart_campus_login_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 navigate_to_invoice_precheck(page: Any) -> bool:
|
||||
"""从智慧校园主界面导航到票据合规预审。"""
|
||||
logger.info("navigate_to_invoice_precheck_start")
|
||||
|
||||
try:
|
||||
# 直接 URL 导航
|
||||
target_url = "https://sandbox.jc2009.com/industries/campus/smartcampus/app/finance/invoice-precheck"
|
||||
await page.goto(target_url, wait_until="domcontentloaded", timeout=NAVIGATION_TIMEOUT_MS)
|
||||
|
||||
# 快速轮询等待页面标题出现
|
||||
async def _title_ready():
|
||||
return await page.get_by_text("票据合规性秒级预审", exact=False).count() > 0
|
||||
|
||||
if await _poll_until(_title_ready, timeout_sec=15.0):
|
||||
logger.info("invoice_precheck_page_loaded")
|
||||
return True
|
||||
|
||||
# fallback: 通过菜单
|
||||
logger.info("direct_nav_failed_try_menu")
|
||||
await page.goto("https://sandbox.jc2009.com/industries/campus/smartcampus/", wait_until="domcontentloaded", timeout=NAVIGATION_TIMEOUT_MS)
|
||||
|
||||
finance_menu = page.get_by_text("财务处", exact=True)
|
||||
if await finance_menu.count() > 0:
|
||||
await finance_menu.click()
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
invoice_menu = page.get_by_text("票据合规预审", exact=True)
|
||||
if await invoice_menu.count() == 0:
|
||||
invoice_menu = page.get_by_text(re.compile(r"票据合规"))
|
||||
if await invoice_menu.count() > 0:
|
||||
await invoice_menu.first.click()
|
||||
if await _poll_until(_title_ready, timeout_sec=15.0):
|
||||
logger.info("invoice_precheck_page_loaded_via_menu")
|
||||
return True
|
||||
|
||||
logger.warning("invoice_precheck_page_not_loaded")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error("navigate_to_invoice_precheck_failed error=%s", str(e))
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user