platform-assistant/scripts/direv-publish/auto_publish.py
Sisyphus afac18e843 feat(F002): W26 + W29.x + W30 + W31 平台助手全部改动首次 commit
- 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
2026-06-24 11:07:11 +08:00

353 lines
13 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
自动发布:从今日下载目录读取公告 → 提取数据 → 发布到 direv
用于 cron 定时任务,发布完成后飞书推送结果
用法:
python3 auto_publish.py # 发布今日所有电建+新矿公告
python3 auto_publish.py --dry-run # 仅列出待发布,不实际发布
"""
import asyncio
import json
import os
import re
import sys
from datetime import date, datetime, timedelta
import fitz
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from batch_publish_v4 import (
publish_one,
fetch_cover_image,
find_notes_file,
compute_delivery,
AUTH_FILE,
CATEGORY_FILE_KEYWORDS,
)
from extract_notes import get_notes, summarize_with_llm, extract_from_pdf
# ============ 配置 ============
PROMPT = """从以下招标公告中提取信息,返回一个 JSON 对象:
{"title":"精简标题≤35字","deadline":"YYYY-MM-DD","region":"省市区县+项目名","category":"合并品类名如'混凝土制品'","unit":"单位","quantity":"总量数字"}
规则:
- deadline:优先用报价截止日期/投标截止日期,没有则用公告发布日期+7天,都没有则用 null
- quantity:把采购一览表中所有行的数量加总求和(数字),不要只取第一行或某一行的值。如果表格中单位不同无法加总,用出现最多的单位,只加总该单位的行
- unit:用出现次数最多的单位
- category 用最上层的品类名(如钢筋混凝土管→混凝土制品,地板→装修材料)
- region 写到区县+项目名即可
- 如果是工程分包/土石方/服务类,返回 {"skip": true}
"""
SKIP_WORDS = ['工程分包', '专业分包', '土石方', '劳务分包']
AUTH_PROFILES = os.path.expanduser("~/.openclaw/agents/main/agent/auth-profiles.json")
def load_deepseek_key():
try:
with open(AUTH_PROFILES) as f:
data = json.load(f)
container = data.get("profiles", data)
for k, v in container.items():
if isinstance(v, dict) and v.get("provider") == "deepseek":
return v.get("key")
except:
pass
return None
def extract_lead_from_pdf(pdf_path, title):
"""用 DeepSeek 从 PDF 中提取 direv 发布所需字段"""
doc = fitz.open(pdf_path)
text = ''.join(page.get_text() for page in doc)[:4000]
doc.close()
api_key = load_deepseek_key()
if not api_key:
raise RuntimeError("DeepSeek API Key 未配置")
import requests
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
payload = {
"model": "deepseek-chat",
"messages": [{"role": "user", "content": PROMPT + "\n原公告标题:" + title + "\n\n公告内容:\n" + text}],
"temperature": 0,
"max_tokens": 500,
"response_format": {"type": "json_object"},
}
r = requests.post("https://api.deepseek.com/v1/chat/completions",
headers=headers, json=payload, timeout=60)
r.raise_for_status()
content = r.json()["choices"][0]["message"]["content"]
content = re.sub(r'^```json?\s*', '', content.strip())
content = re.sub(r'\s*```$', '', content)
return json.loads(content)
def extract_lead_from_html(html_path, title):
"""用 DeepSeek 从 HTML 中提取 direv 发布所需字段"""
with open(html_path, 'r', encoding='utf-8') as f:
html = f.read()
text = re.sub(r'<[^>]+>', ' ', html)
text = re.sub(r'&[a-z]+;', ' ', text)
text = re.sub(r'\s+', ' ', text).strip()[:4000]
api_key = load_deepseek_key()
if not api_key:
raise RuntimeError("DeepSeek API Key 未配置")
import requests
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
payload = {
"model": "deepseek-chat",
"messages": [{"role": "user", "content": PROMPT + "\n原公告标题:" + title + "\n\n公告内容:\n" + text}],
"temperature": 0,
"max_tokens": 500,
"response_format": {"type": "json_object"},
}
r = requests.post("https://api.deepseek.com/v1/chat/completions",
headers=headers, json=payload, timeout=60)
r.raise_for_status()
content = r.json()["choices"][0]["message"]["content"]
content = re.sub(r'^```json?\s*', '', content.strip())
content = re.sub(r'\s*```$', '', content)
return json.loads(content)
def gather_xkgroup_leads(date_dir=None):
"""从新矿集团下载目录收集待发布公告(默认只取最新一天)"""
if date_dir is not None:
base = os.path.expanduser(f"~/desktop/招标公告/{date_dir}")
if not os.path.exists(os.path.join(base, "xkgroup_downloaded.json")):
return []
bases = [base]
else:
today = datetime.now()
# 只取今天目录(新矿每天7天窗口全量抓取,无需扫历史)
date_str = today.strftime("%Y-%m-%d")
base = os.path.expanduser(f"~/desktop/招标公告/{date_str}")
if not os.path.exists(os.path.join(base, "xkgroup_downloaded.json")):
return []
bases = [base]
leads = []
seen_ids = set()
for base in bases:
with open(os.path.join(base, "xkgroup_downloaded.json")) as f:
data = json.load(f)
published_file = os.path.join(base, "direv_published.json")
published = {}
if os.path.exists(published_file):
with open(published_file) as f:
published = json.load(f)
for notice_id, v in data.items():
if notice_id in seen_ids:
continue
seen_ids.add(notice_id)
if v.get("type") != "寻源公告":
continue
title = v.get("title", "")
if any(w in title for w in SKIP_WORDS):
continue
if notice_id in published:
continue
html_path = os.path.join(base, v.get("html", ""))
if not v.get("html") or not os.path.isfile(html_path):
pdf_path = os.path.join(base, v.get("pdf", ""))
if pdf_path and os.path.isfile(pdf_path):
html_path = pdf_path
else:
continue
leads.append({
"key": notice_id,
"title": title,
"html_path": html_path,
"base_dir": base,
"source": "新矿",
"publish_time": v.get("publish_time", ""),
"org": v.get("org", "")
})
return leads
def gather_leads(date_dir=None):
"""从下载目录收集待发布公告(扫描最近7天所有目录)"""
if date_dir is not None:
base = os.path.expanduser(f"~/desktop/招标公告/{date_dir}")
if not os.path.exists(os.path.join(base, "downloaded.json")):
return []
bases = [base]
else:
today = datetime.now()
bases = []
for days_back in range(7):
date_str = (today - timedelta(days=days_back)).strftime("%Y-%m-%d")
base = os.path.expanduser(f"~/desktop/招标公告/{date_str}")
if os.path.exists(os.path.join(base, "downloaded.json")):
bases.append(base)
if not bases:
return []
leads = []
for base in bases:
with open(os.path.join(base, "downloaded.json")) as f:
data = json.load(f)
published_file = os.path.join(base, "direv_published.json")
published = {}
if os.path.exists(published_file):
with open(published_file) as f:
published = json.load(f)
for k, v in data.get("downloaded", {}).items():
if v.get("skipped") or v.get("failed"):
continue
title = v.get("title", "")
if any(w in title for w in SKIP_WORDS):
continue
if k in published:
continue
pdf_path = os.path.join(base, v["path"])
if not os.path.exists(pdf_path):
continue
leads.append({"key": k, "title": title, "pdf_path": pdf_path, "base_dir": base})
return leads
async def auto_publish(dry_run=False):
leads_dianjian = gather_leads()
leads_xinkuang = gather_xkgroup_leads()
leads = leads_dianjian + leads_xinkuang
if not leads:
print("无待发布公告")
return {"total": 0, "success": 0, "skip": 0, "failed": 0}
print(f"待发布: {len(leads)} 条(电建 {len(leads_dianjian)} + 新矿 {len(leads_xinkuang)})\n")
if dry_run:
for i, l in enumerate(leads, 1):
src = l.get("source", "电建")
print(f" {i}. [{src}] {l['title'][:60]}")
return {"total": len(leads), "success": 0, "skip": 0, "failed": 0, "dry_run": True}
# 提取结构化数据
announcements = []
for i, l in enumerate(leads):
cat = l["title"][:40]
src = l.get("source", "电建")
print(f"[{i+1}/{len(leads)}] [{src}] 提取: {cat}...", end=" ", flush=True)
try:
if src == "新矿":
parsed = extract_lead_from_html(l["html_path"], l["title"])
else:
parsed = extract_lead_from_pdf(l.get("pdf_path", l.get("html_path")), l["title"])
if parsed.get("skip"):
print("⏩ 跳过(非货物)")
continue
# 截止日期已过 → 跳过
dl = parsed.get("deadline")
if dl and dl < str(date.today()):
print(f"⏩ 跳过(已截止: {dl})")
continue
# 清理 category 多余空格
if parsed.get("category"):
parsed["category"] = parsed["category"].strip()
# 默认值:unit/quantity 缺失时自动补,避免 direv 表单验证错误
if not parsed.get("unit"):
parsed["unit"] = "批"
if not parsed.get("quantity"):
parsed["quantity"] = "1"
parsed["source"] = src
parsed["notes_path"] = l.get("html_path", l.get("pdf_path", ""))
parsed["_key"] = l["key"]
parsed["_base_dir"] = l["base_dir"]
announcements.append(parsed)
print("✅")
except Exception as e:
print(f"❌ {e}")
if not announcements:
print("无可发布公告")
return {"total": len(leads), "success": 0, "skip": len(leads), "failed": 0}
print(f"\n发布 {len(announcements)} 条...")
# 封面图(先检查缓存,缺失的才调用 MiniMax)
print("预下载封面图...")
cover_paths = {}
for i, lead in enumerate(announcements):
cat = lead.get("category", "建材").strip()
safe = re.sub(r"[^\w\u4e00-\u9fff-]", "_", cat)[:20]
cached = os.path.join("/tmp/direv_covers", f"{safe}_cover.jpg")
print(f" [{i+1}] 封面: {cat}", end=" ", flush=True)
try:
if os.path.exists(cached):
cover_paths[i] = cached
print("✅")
else:
path = await asyncio.wait_for(fetch_cover_image(cat), timeout=90)
cover_paths[i] = path
print("✅")
except asyncio.TimeoutError:
print(f"❌ 超时")
cover_paths[i] = None
except Exception as e:
print(f"❌ {e}")
cover_paths[i] = None
cover_paths[i] = None
# 发布
from playwright.async_api import async_playwright
success = 0
failed = 0
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
for i, lead in enumerate(announcements):
print(f"\n[{i+1}/{len(announcements)}] {lead['title']}")
try:
ok = await publish_one(browser, lead, i, len(announcements), cover_paths.get(i))
if ok:
# 记录已发布
pub_file = os.path.join(lead["_base_dir"], "direv_published.json")
pub_data = {}
if os.path.exists(pub_file):
with open(pub_file) as f:
pub_data = json.load(f)
pub_data[lead["_key"]] = {
"title": lead["title"],
"published_at": datetime.now().isoformat(),
}
with open(pub_file, "w") as f:
json.dump(pub_data, f, ensure_ascii=False, indent=2)
success += 1
else:
failed += 1
except Exception as e:
print(f" ❌ 异常: {e}")
failed += 1
await browser.close()
result = {"total": len(announcements), "success": success, "skip": len(leads) - len(announcements), "failed": failed}
print(f"\n========== 完成 ==========")
print(f"✅ {success} | ❌ {failed} | ⏩ {result['skip']}")
return result
if __name__ == "__main__":
dry_run = "--dry-run" in sys.argv
result = asyncio.run(auto_publish(dry_run=dry_run))
if result.get("dry_run"):
sys.exit(0)
if result["failed"] > 0:
sys.exit(1)