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