- W26: NestJS API + Next.js Web + Prisma + SQLite scaffold - W26: Lead 模型 + Crawler + 询价发布助手 (线索池 + 一键发布) - W29.x: MCP 集成 (Integration 模型 + connectMcp + publish_rfq 5 工具端到端跑通) - W30: 3 字段补充 (notes / quotationType / quotationDemand) - W31 P1: 自动填 enterpriseId / userId (Prisma companyUserId 字段 + get_app_identity tool + lead.service.ts publishArgs) - W31: detail 详情页 + admin UI 集成管理 (OpenAppListView + AuditLogListView) 关联: ~/Documents/02-平台开发/产品开发/F002-W31-交接文档.md GIT-AUDIT-REPORT-2026-06-24.md
186 lines
5.6 KiB
Python
Executable File
186 lines
5.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
从招标公告 PDF/HTML 中提取关键商业要求,整理成要点摘要。
|
||
|
||
用法:
|
||
python3 extract_notes.py <pdf_or_html_path>
|
||
|
||
输出: 纯文本要点(每条以 - 开头),可直接填入 direv 备注字段
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import re
|
||
import sys
|
||
|
||
import fitz # PyMuPDF
|
||
import requests
|
||
from bs4 import BeautifulSoup
|
||
|
||
# ============ 配置 ============
|
||
AUTH_FILE = os.path.expanduser("~/.openclaw/agents/main/agent/auth-profiles.json")
|
||
LLM_ENDPOINT = "https://api.deepseek.com/v1/chat/completions"
|
||
LLM_MODEL = "deepseek-chat"
|
||
LLM_TIMEOUT = 60
|
||
MAX_CHARS = 4000 # 输入文本最大字符数(太长则截断)
|
||
|
||
|
||
def load_llm_key():
|
||
"""从 OpenClaw auth-profiles 读取 DeepSeek API Key"""
|
||
try:
|
||
with open(AUTH_FILE) as f:
|
||
data = json.load(f)
|
||
container = data.get("profiles", data)
|
||
if not isinstance(container, dict):
|
||
return None
|
||
for key, val in container.items():
|
||
if isinstance(val, dict) and val.get("provider") == "deepseek":
|
||
return val.get("key")
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
# ============ 文本提取 ============
|
||
|
||
|
||
def extract_from_pdf(filepath: str) -> str:
|
||
"""从 PDF 提取纯文本"""
|
||
doc = fitz.open(filepath)
|
||
pages = []
|
||
for page in doc:
|
||
text = page.get_text()
|
||
if text.strip():
|
||
pages.append(text.strip())
|
||
doc.close()
|
||
return "\n---\n".join(pages)
|
||
|
||
|
||
def extract_from_html(filepath: str) -> str:
|
||
"""从 HTML 提取可见文本(过滤 script/style)"""
|
||
with open(filepath, "r", encoding="utf-8") as f:
|
||
html = f.read()
|
||
soup = BeautifulSoup(html, "lxml" if "lxml" in sys.modules else "html.parser")
|
||
for tag in soup(["script", "style", "meta", "link", "noscript"]):
|
||
tag.decompose()
|
||
text = soup.get_text(separator="\n")
|
||
lines = [line.strip() for line in text.split("\n") if line.strip()]
|
||
return "\n".join(lines)
|
||
|
||
|
||
# ============ LLM 摘要 ============
|
||
|
||
SYSTEM_PROMPT = """你是一个招标公告信息提取助手。请从以下公告内容中提取关键商业要求,整理成简明要点。
|
||
|
||
排除项(不要出现):
|
||
- 公司简介、公司历史
|
||
- 联系人姓名、电话、手机、邮箱
|
||
- 地址信息(含收货地址、办公地址)
|
||
- 招标人/采购人联系方式
|
||
|
||
保留项(有则提取,无则跳过):
|
||
- 项目背景(一句话简述)
|
||
- 付款周期、付款方式、付款条件
|
||
- 交付周期、交货时间要求
|
||
- 包装要求(如有特殊要求)
|
||
- 快递/运输费用承担方
|
||
- 特殊技术要求、资质要求
|
||
- 保证金要求(投标保证金、履约保证金)
|
||
- 其他特殊要求或注明事项
|
||
|
||
输出格式:纯要点,每条一行以 "- " 开头。不要标题、分类头、解释文字。
|
||
每条要点控制在20字以内,最多输出6条。如果某类信息原文没提到,就跳过不写。"""
|
||
|
||
|
||
def summarize_with_llm(raw_text: str) -> str:
|
||
"""用 DeepSeek LLM 将公告内容提取为要点摘要"""
|
||
api_key = load_llm_key()
|
||
if not api_key:
|
||
raise RuntimeError("DeepSeek API Key 未配置")
|
||
|
||
if len(raw_text) > MAX_CHARS:
|
||
raw_text = raw_text[:MAX_CHARS] + "\n[内容过长已截断]"
|
||
|
||
headers = {
|
||
"Authorization": f"Bearer {api_key}",
|
||
"Content-Type": "application/json",
|
||
}
|
||
payload = {
|
||
"model": LLM_MODEL,
|
||
"messages": [
|
||
{"role": "system", "content": SYSTEM_PROMPT},
|
||
{"role": "user", "content": raw_text},
|
||
],
|
||
"temperature": 0.1,
|
||
"max_tokens": 500,
|
||
}
|
||
|
||
r = requests.post(LLM_ENDPOINT, headers=headers, json=payload, timeout=LLM_TIMEOUT)
|
||
if r.status_code != 200:
|
||
raise RuntimeError(f"DeepSeek API 返回 {r.status_code}: {r.text[:200]}")
|
||
|
||
data = r.json()
|
||
content = ""
|
||
choices = data.get("choices", [])
|
||
if choices:
|
||
content = choices[0].get("message", {}).get("content", "")
|
||
if not content:
|
||
raise RuntimeError(f"DeepSeek 返回空内容: {json.dumps(data, ensure_ascii=False)[:300]}")
|
||
|
||
# 清理输出
|
||
lines = [line.strip() for line in content.split("\n") if line.strip()]
|
||
return "\n".join(lines[:6]) # 最多6条
|
||
|
||
|
||
# ============ 主入口 ============
|
||
|
||
|
||
def get_notes(source_type: str, source_path: str) -> str:
|
||
"""
|
||
从公告文件提取备注内容
|
||
|
||
Args:
|
||
source_type: "pdf" 或 "html"
|
||
source_path: 文件路径
|
||
|
||
Returns:
|
||
要点字符串,如 "- 项目背景...\n- 付款周期..."
|
||
"""
|
||
if not os.path.exists(source_path):
|
||
raise FileNotFoundError(f"文件不存在: {source_path}")
|
||
|
||
if source_type == "pdf":
|
||
raw_text = extract_from_pdf(source_path)
|
||
elif source_type == "html":
|
||
raw_text = extract_from_html(source_path)
|
||
else:
|
||
raise ValueError(f"不支持的类型: {source_type}")
|
||
|
||
if not raw_text or len(raw_text) < 20:
|
||
raise ValueError(f"文件内容过短或无法提取 ({len(raw_text)} 字符)")
|
||
|
||
print(f" 原始文本长度: {len(raw_text)} 字符")
|
||
notes = summarize_with_llm(raw_text)
|
||
print(f" LLM 摘要: {notes}")
|
||
return notes
|
||
|
||
|
||
# ============ CLI ============
|
||
if __name__ == "__main__":
|
||
if len(sys.argv) < 2:
|
||
print("用法: python3 extract_notes.py <pdf_or_html_path>")
|
||
print("自动判断文件类型")
|
||
sys.exit(1)
|
||
|
||
path = sys.argv[1]
|
||
ext = os.path.splitext(path)[1].lower()
|
||
source_type = "pdf" if ext == ".pdf" else "html"
|
||
|
||
try:
|
||
result = get_notes(source_type, path)
|
||
print("\n--- 备注内容 ---")
|
||
print(result)
|
||
except Exception as e:
|
||
print(f"❌ 错误: {e}")
|
||
sys.exit(1)
|