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

433 lines
15 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(v3 - 加图片搜索上传)
"""
import asyncio
import io
import json
import os
import re
import sys
from datetime import datetime, timedelta
from urllib.parse import quote, parse_qs, urlparse
import requests
from PIL import Image
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
# ============ 商品名 → 英文搜索关键词映射 ============
CATEGORY_KEYWORDS = {
"闭孔泡沫板": "closed cell foam board insulation polyethylene",
"土工格栅": "geogrid reinforcement mesh geotextile",
"土工隔栅": "geogrid reinforcement mesh geotextile",
"铝扣板": "aluminum ceiling panel 600x600mm",
"高强灌浆料": "high strength grout cement structural",
"干式变压器": "dry type transformer electrical",
"聚氨酯筛网": "polyurethane screen mesh mining industrial",
"铜止水": "copper waterstop dam seal construction",
"商品混凝土": "ready mix concrete construction",
"钢筋": "rebar steel bar construction",
"钢材": "steel bar angle channel construction",
"阀门": "industrial valve gate ball valve",
"电线电缆": "electric wire cable power",
"水泥": "cement portland construction",
"防水材料": "waterproof membrane building material",
}
# ============ 公告定义(交货日期自动 = deadline + 30天)============
ANNOUNCEMENTS = [
{
"title": "郑州市七里河分洪工程闭孔泡沫板采购",
"deadline": "2026-05-13",
"region": "郑州市七里河分洪工程项目施工现场",
"category": "闭孔泡沫板",
"unit": "m³",
"quantity": "1300",
},
{
"title": "郑州市七里河分洪工程土工格栅采购",
"deadline": "2026-05-13",
"region": "郑州市七里河分洪工程项目施工现场",
"category": "土工格栅",
"unit": "㎡",
"quantity": "30000",
},
{
"title": "锦屏水电站铝扣板材料采购",
"deadline": "2026-05-13",
"region": "四川省凉山彝族自治州西昌市锦屏水电站",
"category": "铝扣板",
"unit": "㎡",
"quantity": "3000",
},
{
"title": "金塔县风电项目高强灌浆料采购",
"deadline": "2026-05-13",
"region": "甘肃省酒泉市金塔县中国水利水电第十一工程局中核汇能金塔县160万千瓦清洁能源保障项目工地现场",
"category": "高强灌浆料",
"unit": "吨",
"quantity": "150",
},
]
COVER_DIR = "/tmp/direv_covers"
def compute_delivery(deadline_str: str) -> str:
d = datetime.strptime(deadline_str, "%Y-%m-%d")
return (d + timedelta(days=30)).strftime("%Y-%m-%d")
def get_search_keywords(category: str) -> list:
if category in CATEGORY_KEYWORDS:
return [CATEGORY_KEYWORDS[category], category]
return [category]
# ============ 图片搜索下载 ============
BRAND_DOMAINS = {
"3m", "dupont", "basf", "sika", "hilti", "bosch", "dewalt",
"makita", "stanley", "握\u9519", "\u54c1\u724c", "\u65d7\u8230\u5e97", "\u5b98\u7f51",
}
def is_brand_image(mediaurl: str) -> bool:
"""检查图片URL是否来自品牌官网"""
lower = mediaurl.lower()
for brand in BRAND_DOMAINS:
if brand in lower:
return True
return False
async def search_download_image(keyword: str) -> bytes | None:
"""用 Playwright + Bing 图片搜索,尝试最多3张图,跳过品牌图"""
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
page.set_default_timeout(25000)
try:
await page.goto(
f"https://www.bing.com/images/search?q={quote(keyword)}&form=PCVS",
wait_until="domcontentloaded"
)
await page.wait_for_timeout(3000)
imgs = await page.query_selector_all(".mimg")
if not imgs:
return None
# 尝试最多3张图
for attempt in range(min(3, len(imgs))):
try:
await imgs[attempt].click()
await page.wait_for_timeout(2000)
parsed = urlparse(page.url)
params = parse_qs(parsed.query)
mediaurls = params.get("mediaurl", [])
if not mediaurls:
continue
mediaurl = mediaurls[0]
# 跳过品牌图
if is_brand_image(mediaurl):
print(f" 跳过品牌图: {mediaurl[:60]}")
continue
resp = requests.get(mediaurl, timeout=15, headers={
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
})
if resp.status_code == 200 and len(resp.content) > 10000:
return resp.content
except Exception:
continue
except Exception:
pass
finally:
await browser.close()
return None
async def ai_generate_cover(category: str, filepath: str) -> bool:
"""用 MiniMax image-01 生成商品封面图"""
# 从 OpenClaw auth-profiles 读取 MiniMax API Key
auth_path = os.path.expanduser("~/.openclaw/agents/main/agent/auth-profiles.json")
api_key = None
try:
import pathlib
with open(auth_path) as f:
profiles = json.load(f)
for profile_key, profile_val in profiles.items():
if isinstance(profile_val, dict) and profile_val.get("provider") == "minimax":
api_key = profile_val.get("key")
break
except Exception:
pass
if not api_key:
return False
# prompt 模板
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",
}
# 尝试双端点(Global / CN)
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 生成,不兜底)
失败重试 max_retries 次,全失败则抛出异常
"""
import pathlib
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
# AI 生成 + 重试
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)
# 点击「上传商品封面」按钮,触发 Playwright file_chooser
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)
lead["delivery"] = compute_delivery(lead["deadline"])
print(f"\n[{idx+1}/{total}] {lead['title']}")
print(f" 交货日期: {lead['delivery']}")
# 导航
await page.goto("https://www.direv.com.cn/#/index", wait_until="domcontentloaded")
await page.wait_for_timeout(5000)
# 点击发布询价(优先 button 精确匹配 → 兜底模糊匹配)
clicked = False
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
if not clicked:
for el in await page.query_selector_all("span, div, button"):
try:
if ((await el.text_content()) or "").strip() == "发布询价信息":
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)
# 上传封面图
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")
# 预下载所有封面图(headless浏览器)
print("预下载封面图...")
cover_paths = {}
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
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" ❌ 搜图失败")
await browser.close()
# 发布
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))