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,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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user