#!/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)