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