- 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
433 lines
15 KiB
Python
Executable File
433 lines
15 KiB
Python
Executable File
#!/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))
|