platform-assistant/scripts/direv-publish/batch_publish_v4.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

556 lines
21 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.com.cn(v4 — 备注 + PDF 上传)
基于 v3,新增:
- 从招标公告 PDF/HTML 提取关键商业要求,填入"备注"字段
- 电建公告:上传公告 PDF 到备注区
- 新矿公告:仅填备注文本,不上传 PDF
"""
import asyncio
import json
import os
import re
import sys
from datetime import datetime, timedelta
from playwright.async_api import async_playwright
sys.path.insert(0, str(__file__).rsplit("/", 1)[0])
from publish import fill_form, submit_form, AUTH_FILE
from extract_notes import get_notes
# ============ 公告定义 ============
# 新增字段:
# source: "电建" | "新矿"
# notes_path: PDF/HTML 文件路径(可选,留空则自动匹配)
ANNOUNCEMENTS = [
# ==== 昨天 (5/18) 电建公告 ====
# 1. 莱索托水泵
{
"title": "莱索托高地引水项目II期水泵采购",
"deadline": "2026-05-26",
"region": "天津或上海港等国内指定港口仓库",
"category": "水泵",
"unit": "台",
"quantity": "10",
"source": "电建",
"notes_path": "",
},
# 2. 九峰山钢材
{
"title": "九峰山抽水蓄能电站工字钢槽钢镀锌扁铁采购",
"deadline": "2026-05-26",
"region": "河南省新乡市辉县市九峰山抽水蓄能电站",
"category": "钢材",
"unit": "吨",
"quantity": "335",
"source": "电建",
"notes_path": "",
},
# 3. 武铁砌块
{
"title": "武铁信阳生产基地蒸压加气混凝土砌块采购",
"deadline": "2026-05-22",
"region": "河南省信阳市武铁项目施工现场",
"category": "蒸压加气混凝土砌块",
"unit": "m³",
"quantity": "4500",
"source": "电建",
"notes_path": "",
},
# 4. 九峰山气体
{
"title": "九峰山抽水蓄能电站工业气体采购",
"deadline": "2026-05-26",
"region": "河南省新乡市辉县市九峰山抽水蓄能电站",
"category": "工业气体",
"unit": "瓶",
"quantity": "2070",
"source": "电建",
"notes_path": "",
},
# 5. 庙湾水库打包箱房
{
"title": "郑州市庙湾水库打包箱房采购",
"deadline": "2026-05-25",
"region": "河南省郑州市高新区庙湾水库工程现场",
"category": "打包箱房",
"unit": "间",
"quantity": "105",
"source": "电建",
"notes_path": "",
},
# 6. 红棉大道混凝土制品(30项合并为一条,用规格区分)
{
"title": "红棉大道工程混凝土制品采购",
"deadline": "2026-05-25",
"region": "广东省广州市花都区红棉大道工程田心立交至风神立交段",
"category": "混凝土制品",
"unit": "批",
"quantity": "1",
"source": "电建",
"notes_path": "",
},
# 7. 巩义人防封堵板
{
"title": "巩义人民医院东区医院人防封堵板采购",
"deadline": "2026-05-25",
"region": "河南省巩义市巩义市人民医院东区医院二期续建工程",
"category": "人防封堵板",
"unit": "樘",
"quantity": "23",
"source": "电建",
"notes_path": "",
},
# 8. 紫坪铺装修材料
{
"title": "紫坪铺项目装修材料采购",
"deadline": "2026-05-25",
"region": "四川省成都市都江堰市龙池镇紫坪铺项目",
"category": "装修材料",
"unit": "批",
"quantity": "1",
"source": "电建",
"notes_path": "",
},
]
COVER_DIR = "/tmp/direv_covers"
# ============ 品类→文件名关键词映射 ============
# 用于精确匹配品类名与公告 PDF 文件名(品类名和文件名词不同时使用)
CATEGORY_FILE_KEYWORDS = {
"工业气体": ["液态氧气", "液态二氧化碳", "丙烷", "乙炔"],
"混凝土制品": ["钢筋混凝土管", "混凝土检查井", "透水砖", "电缆沟盖板", "仿花岗岩", "预制砼"],
"装修材料": ["木地板", "踢脚线", "防盗门", "玻璃隔断", "石膏板吊顶"],
"打包箱房": ["打包箱房"],
"水泵": ["水泵"],
"人防封堵板": ["人防封堵板", "封堵板"],
"蒸压加气混凝土砌块": ["蒸压加气混凝土砌块", "混凝土砌块"],
}
# ============ 线索→文件匹配 ============
def find_notes_file(lead: dict) -> str | None:
"""
为一条线索自动匹配 PDF(电建)或 HTML(新矿)文件。
从最近 7 天的下载目录搜索,按标题/品类关键词匹配。
"""
source = lead.get("source", "电建")
ext = "pdf" if source == "电建" else "html"
# 提取关键词
category = lead.get("category", "")
title = lead.get("title", "")
proj_keywords = []
for m in re.finditer(r"[\u4e00-\u9fff]{2,}(?:工程|项目|电站|水库|风电)", title):
proj_keywords.append(m.group())
today = datetime.now()
all_candidates = []
for days_back in range(7):
date_dir = (today - timedelta(days=days_back)).strftime("%Y-%m-%d")
search_dir = os.path.expanduser(f"~/desktop/招标公告/{date_dir}/{ext}")
if not os.path.isdir(search_dir):
continue
for fname in os.listdir(search_dir):
if not fname.endswith(f".{ext}"):
continue
full_path = os.path.join(search_dir, fname)
score = 0
if category in fname:
score += 10
# 品类→文件名关键词映射(补充匹配)
extra_kws = CATEGORY_FILE_KEYWORDS.get(category, [])
for kw in extra_kws:
if kw in fname:
score += 10
break # 命中一个关键词即可
for kw in proj_keywords:
if kw in fname:
score += 5
if score > 0:
all_candidates.append((score, full_path, fname))
# 如果当前目录已有候选且来自最近2天,优先使用
if all_candidates and days_back < 2:
all_candidates.sort(key=lambda x: x[0], reverse=True)
best = all_candidates[0]
print(f" 自动匹配: {best[2]}")
return best[1]
if not all_candidates:
return None
all_candidates.sort(key=lambda x: x[0], reverse=True)
best = all_candidates[0]
print(f" 自动匹配: {best[2]}")
return best[1]
def compute_delivery(deadline_str: str) -> str:
if not deadline_str:
# 默认:今天 + 30 天
d = datetime.now() + timedelta(days=30)
else:
try:
d = datetime.strptime(deadline_str, "%Y-%m-%d")
except (ValueError, TypeError):
d = datetime.now() + timedelta(days=30)
return d.strftime("%Y-%m-%d")
# ============ 备注填充 ============
async def fill_notes(page, notes_text: str):
"""填入备注 textarea"""
await page.evaluate("""(text) => {
const ta = document.querySelector('.beizhu textarea.el-textarea__inner');
if (!ta) return false;
Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype, 'value'
).set.call(ta, text);
ta.dispatchEvent(new Event('input', {bubbles: true}));
ta.dispatchEvent(new Event('change', {bubbles: true}));
return true;
}""", notes_text)
print(f" ✓ 备注已填入 ({len(notes_text)} 字)")
async def upload_pdf(page, pdf_path: str):
"""上传 PDF 到备注区"""
try:
await page.wait_for_timeout(500)
# 方式1:直接 setInputFiles(更可靠,不需要 file_chooser 事件)
uploaded = await page.evaluate("""() => {
const input = document.querySelector('.beizhu .el-upload__input');
return !!input;
}""")
if uploaded:
# 用 Playwright locator 定位 file input 并直接 set input files
file_input = page.locator('.beizhu .el-upload__input')
await file_input.set_input_files(pdf_path)
await page.wait_for_timeout(3000)
print(f" ✓ PDF 已上传: {os.path.basename(pdf_path)}")
return True
# 方式2:fallback — 点击上传区域触发 file_chooser
async with page.expect_file_chooser(timeout=5000) as fc_info:
# 点击 up_con 内的任意可点击区域
await page.evaluate("""() => {
const upCon = document.querySelector('.beizhu .up_con');
if (!upCon) return;
// 尝试点击内部元素
const clickable = upCon.querySelector('*') || upCon;
clickable.click();
}""")
file_chooser = await fc_info.value
await file_chooser.set_files(pdf_path)
await page.wait_for_timeout(3000)
print(f" ✓ PDF 已上传: {os.path.basename(pdf_path)}")
return True
except Exception as e:
print(f" ⚠️ PDF 上传异常: {e}")
return False
# ============ 封面图 ============
async def ai_generate_cover(category: str, filepath: str) -> bool:
"""用 MiniMax image-01 生成商品封面图"""
auth_path = os.path.expanduser("~/.openclaw/agents/main/agent/auth-profiles.json")
api_key = None
try:
with open(auth_path) as f:
profiles = json.load(f)
container = profiles.get("profiles", profiles)
if isinstance(container, dict):
for key, val in container.items():
if isinstance(val, dict) and val.get("provider") == "minimax":
api_key = val.get("key")
break
except Exception:
pass
if not api_key:
return False
prompt_map = {
"石灰": "石灰,工业用生石灰,白色块状或粉末状,建筑材料,白色背景,高清商品展示,表面无任何文字",
"水泥": "水泥,建筑用普通硅酸盐水泥,灰色粉末,袋装水泥堆放,建筑材料,白色背景,高清摄影,包装袋表面无任何文字",
"钢筋": "钢筋,建筑用螺纹钢,热轧带肋钢筋HRB400,金属质感,白色背景,高清商品摄影,表面无任何文字",
"钢材": "钢材,工字钢H型钢槽钢,建筑钢结构材料,金属质感,工业材料,白色背景,高清商品展示,表面无任何文字",
"选矿药剂": "选矿药剂,工业浮选用化学药剂,化工原料桶装,矿业用品,白色背景,商品展示,高清摄影,桶面无任何文字",
"水泵": "工业水泵,离心泵潜水泵,金属铸造泵体,工业设备,白色背景,高清商品摄影,表面无任何文字",
"蒸压加气混凝土砌块": "蒸压加气混凝土砌块,灰白色轻质砌块,建筑材料堆场,白色背景,高清摄影,表面无任何文字",
"工业气体": "工业气体,氧气乙炔丙烷钢瓶,金属气瓶排列,工业用品,白色背景,高清摄影,瓶身无任何文字",
"打包箱房": "打包箱房,工地临时活动板房,钢结构模块化箱房,建筑临建,白色背景,高清展示,表面无任何文字",
"混凝土制品": "混凝土制品,钢筋混凝土管检查井盖板,预制构件堆场,建筑材料,白色背景,高清摄影,表面无任何文字",
"人防封堵板": "人防封堵板,人防工程防护设备,钢制防护门封堵板,建筑安全设备,白色背景,高清商品展示,表面无任何文字",
"装修材料": "装修材料,木地板防盗门玻璃隔断,室内装饰建材,白色背景,高清商品展示,表面无任何文字",
}
default_prompt = f"{category},工业用{category}产品,建筑材料或工业原料,白色背景,商品展示,高清摄影,表面无任何文字"
prompt = prompt_map.get(category, default_prompt)
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
payload = {"model": "image-01", "prompt": prompt, "n": 1, "aspect_ratio": "1:1"}
import io as _io
import requests as _requests
from PIL import Image as _Image
endpoints = ["https://api.minimax.chat/v1/image_generation", "https://api.minimaxi.com/v1/image_generation"]
for url in endpoints:
try:
r = _requests.post(url, headers=headers, json=payload, timeout=60)
if r.status_code != 200:
continue
data = r.json()
img_urls = data.get("data", {}).get("image_urls", [])
if not img_urls:
continue
img_resp = _requests.get(img_urls[0], timeout=30)
if img_resp.status_code != 200 or len(img_resp.content) < 5000:
continue
img = _Image.open(_io.BytesIO(img_resp.content)).convert("RGB")
w, h = img.size
if w > h:
left = (w - h) // 2
img = img.crop((left, 0, left + h, h))
elif h > w:
top = (h - w) // 2
img = img.crop((0, top, w, top + w))
img = img.resize((400, 400), _Image.LANCZOS)
buf = _io.BytesIO()
img.save(buf, "JPEG", quality=85, optimize=True)
processed = buf.getvalue()
if len(processed) < 5000:
continue
with open(filepath, "wb") as f:
f.write(processed)
return True
except Exception:
continue
return False
async def fetch_cover_image(category: str, max_retries: int = 3) -> str:
"""获取商品封面图(AI 生成)"""
category = category.strip()
os.makedirs(COVER_DIR, exist_ok=True)
safe = re.sub(r"[^\w\u4e00-\u9fff-]", "_", category)[:20]
filepath = os.path.join(COVER_DIR, f"{safe}_cover.jpg")
if os.path.exists(filepath):
return filepath
for attempt in range(1, max_retries + 1):
print(f" MiniMax 生图: {category}(第{attempt}次)")
ok = await ai_generate_cover(category, filepath)
if ok:
size = os.path.getsize(filepath)
print(f" ✓ AI 生成成功 ({size//1024}KB)")
return filepath
if attempt < max_retries:
print(f" ⚠️ 第{attempt}次失败,重试...")
await asyncio.sleep(2)
raise RuntimeError(f"AI 生图失败: {category}(重试{max_retries}次均失败)")
async def upload_cover_image(page, row_index: int, image_path: str) -> bool:
"""上传商品封面图到询价物料表格"""
try:
await page.wait_for_timeout(1000)
async with page.expect_file_chooser() as fc_info:
clicked = await page.evaluate("""([idx]) => {
var rows = document.querySelectorAll('.el-table__body-wrapper tbody tr');
if (!rows[idx]) return false;
var els = rows[idx].querySelectorAll('*');
for (var el of els) {
if (el.children.length === 0 && el.textContent.includes('上传商品封面')) {
el.click();
return true;
}
}
return false;
}""", [row_index])
if not clicked:
print(f" 未找到第{row_index+1}行上传按钮")
return False
file_chooser = await fc_info.value
await file_chooser.set_files(image_path)
await page.wait_for_timeout(4000)
print(f" ✓ 封面图已上传")
return True
except Exception as e:
print(f" 上传异常: {e}")
return False
# ============ 发布单条 ============
async def publish_one(browser, lead: dict, idx: int, total: int, cover_path: str | None):
ctx_options = {"viewport": {"width": 1440, "height": 900}}
if os.path.exists(AUTH_FILE):
ctx_options["storage_state"] = AUTH_FILE
ctx = await browser.new_context(**ctx_options)
page = await ctx.new_page()
page.set_default_timeout(15000)
lead = dict(lead)
# deadline 默认兜底:今天 + 7 天
if not lead.get("deadline"):
from datetime import datetime, timedelta
lead["deadline"] = (datetime.now() + timedelta(days=7)).strftime("%Y-%m-%d")
lead["delivery"] = compute_delivery(lead["deadline"])
print(f"\n[{idx+1}/{total}] {lead['title']}")
print(f" 交货日期: {lead['delivery']}")
# --- 处理备注内容 ---
source = lead.get("source", "电建")
notes_path = lead.get("notes_path", "")
# 自动匹配文件
if not notes_path:
notes_path = find_notes_file(lead)
notes_text = ""
if notes_path and os.path.exists(notes_path):
try:
ext = os.path.splitext(notes_path)[1].lower()
source_type = "pdf" if ext == ".pdf" else "html"
notes_text = get_notes(source_type, notes_path)
print(f" 备注摘要: {notes_text[:80]}...")
except Exception as e:
print(f" ⚠️ 备注提取失败: {e}")
# --- 导航 ---
await page.goto("https://www.direv.com.cn/#/index", wait_until="domcontentloaded")
await page.wait_for_timeout(5000)
# 点击发布询价(优先 button,精确匹配 → 模糊匹配)
clicked = False
# 1) 优先找 button 元素,文字精确为「发布询价」
for el in await page.query_selector_all("button"):
try:
if (await el.text_content() or "").strip() == "发布询价":
await el.click()
clicked = True
break
except Exception:
pass
# 2) 兜底:模糊匹配所有元素(兼容旧文案「发布询价信息」)
if not clicked:
for el in await page.query_selector_all("span, div, button"):
try:
text = (await el.text_content() or "").strip()
if text == "发布询价信息":
await el.click()
clicked = True
break
except Exception:
pass
if not clicked:
print(f" ❌ 未找到发布按钮")
await ctx.close()
return False
await page.wait_for_timeout(8000)
try:
await page.wait_for_selector(".c_title", timeout=15000)
except Exception:
print(f" ❌ 表单未加载")
await ctx.close()
return False
# --- 填表 ---
await fill_form(page, lead)
# --- 备注 + PDF 上传 ---
if notes_text:
await fill_notes(page, notes_text)
if notes_path and source == "电建" and notes_path.endswith(".pdf"):
await upload_pdf(page, notes_path)
# --- 上传封面图 ---
if cover_path and os.path.exists(cover_path):
print(f" 上传封面: {os.path.basename(cover_path)} ({os.path.getsize(cover_path)//1024}KB)")
await upload_cover_image(page, 0, cover_path)
else:
print(f" ⚠️ 无封面图")
# --- 提交 ---
print(f" 提交...")
ok = await submit_form(page)
if not ok:
print(f" ❌ 验证错误")
await page.screenshot(path=f"/tmp/direv_err_{lead['title'][:10]}.png", full_page=True)
await ctx.close()
return False
await page.wait_for_timeout(3000)
await page.screenshot(path=f"/tmp/direv_{idx+1}_{lead['title'][:10]}.png", full_page=True)
print(f" ✅ 发布成功")
await ctx.close()
return True
# ============ 主流程 ============
async def batch_publish(headless=True):
print(f"模式: {'headless' if headless else 'headed'}")
print(f"待发布: {len(ANNOUNCEMENTS)} 条\n")
# 预下载封面图
print("预下载封面图...")
cover_paths = {}
for i, lead in enumerate(ANNOUNCEMENTS):
cat = lead["category"]
print(f" [{i+1}/{len(ANNOUNCEMENTS)}] 封面: {cat}")
path = await fetch_cover_image(cat)
cover_paths[i] = path
if path:
size = os.path.getsize(path)
print(f" ✅ {os.path.basename(path)} ({size//1024}KB)")
else:
print(f" ❌ 生图失败")
# 发布
async with async_playwright() as p:
browser = await p.chromium.launch(headless=headless)
success = 0
for i, lead in enumerate(ANNOUNCEMENTS):
try:
ok = await publish_one(browser, lead, i, len(ANNOUNCEMENTS), cover_paths.get(i))
if ok:
success += 1
except Exception as e:
print(f" ❌ 异常: {e}")
await browser.close()
print(f"\n========== 完成 ==========")
print(f"成功: {success}/{len(ANNOUNCEMENTS)} 条")
if __name__ == "__main__":
headless = "--headed" not in sys.argv
asyncio.run(batch_publish(headless=headless))