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