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

288 lines
11 KiB
Python
Executable File
Raw Permalink 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 询价自动发布
用法:
python3 publish.py # headless 模式(需要已有登录态)
python3 publish.py --headed # 首次使用或登录态过期时使用
依赖: pip3 install playwright && playwright install chromium
"""
import asyncio
import os
import sys
from playwright.async_api import async_playwright
# ============ 固定配置 ============
CONTACT_NAME = "郑钊"
CONTACT_PHONE = "15532027557"
AUTH_FILE = "/tmp/direv-auth.json"
# ============ 待发布线索(每次修改这里)============
LEAD = {
"title": "赵王新河治理工程(雄县段)二期施工二标段块石采购",
"deadline": "2026-05-09",
"delivery": "2026-05-16",
"region": "河北省保定市雄县 赵王新河治理工程(雄县段)二期施工二标段项目施工现场",
"category": "块石",
"unit": "吨",
"quantity": "500",
}
async def fill_form(page, lead):
"""自动填充 create_square 表单全部 15 个字段"""
FILL_INPUT = """(d) => {
var titles = document.querySelectorAll(".c_title");
for (var i=0; i<titles.length; i++) {
var label = titles[i].textContent.trim()
.replace(/^\\*\\s*/, "").trim()
.split(/[\\n\\r]+/)[0];
if (!label.startsWith(d.label)) continue;
var container = titles[i].closest(".c_one")
|| titles[i].closest(".c_two")
|| titles[i].parentElement;
var el = container && (
container.querySelector(".el-input__inner")
|| container.querySelector("input")
);
if (el) {
Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype, "value"
).set.call(el, d.value);
el.dispatchEvent(new Event("input", {bubbles:true}));
el.dispatchEvent(new Event("change", {bubbles:true}));
}
}
}"""
fields = [
("询价单标题", lead["title"][:50]),
("询价截止日期", lead["deadline"]),
("采购联系人", CONTACT_NAME),
("联系方式", CONTACT_PHONE),
("报价要求", "报价包含运费"),
("要求交货日期", lead["delivery"]),
("收货地址", lead["region"]),
]
for label, value in fields:
await page.evaluate(FILL_INPUT, {"label": label, "value": value})
# 商品表格(支持多行 specs)
specs = lead.get("specs", [])
if not specs:
specs = [{
"category": lead["category"],
"spec": lead.get("spec", ""),
"unit": lead["unit"],
"quantity": lead["quantity"],
}]
for row_i, row_data in enumerate(specs):
if row_i > 0:
# 用 Playwright locator 点击 +添加物料(比 evaluate DOM 更可靠)
await page.locator("text=添加物料").first.click()
await page.wait_for_timeout(2000)
await page.evaluate(
"""(d) => {
var rows = document.querySelectorAll(
".el-table__body-wrapper tbody tr"
);
var row = rows[d.i];
if (!row) return;
var cells = row.querySelectorAll("td");
// cell[2]=商品名称, cell[4]=规格型号, cell[5]=单位, cell[6]=数量, cell[7]=询价备注
var map = {2: d.cat, 4: d.spec, 5: d.unit, 6: d.qty, 7: d.remark};
for (var ci in map) {
var el = cells[ci].querySelector(".el-input__inner");
if (el) {
// 确保值是字符串(Element UI 有时对数字类型敏感)
var val = map[ci];
if (val !== null && val !== undefined) val = String(val);
var nativeSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype, "value"
).set;
nativeSetter.call(el, val);
// 触发多个事件确保 Vue/Element UI 响应
el.dispatchEvent(new Event("input", {bubbles:true}));
el.dispatchEvent(new Event("change", {bubbles:true}));
// 对数量字段额外触发 compositionend(Vue v-model 的 composition 管道)
if (ci === "6") {
el.dispatchEvent(new CompositionEvent("compositionstart", {bubbles:true}));
el.dispatchEvent(new CompositionEvent("compositionend", {bubbles:true}));
el.focus();
el.blur();
}
}
}
}""",
{"i": row_i, "cat": row_data["category"], "spec": row_data.get("spec", ""),
"unit": row_data["unit"], "qty": str(row_data.get("quantity", "")).strip(), "remark": row_data.get("remark", "")},
)
# 采购类型 → 定量采购
await page.evaluate('document.querySelectorAll(".el-select__wrapper")[0].click()')
await page.wait_for_timeout(500)
await page.evaluate(
'(function(){'
'var o=document.querySelectorAll(".el-select-dropdown__item");'
'for(var i=0;i<o.length;i++)'
'{if(o[i].textContent.includes("定量采购"))o[i].click();}'
'})()'
)
# 付款方式 → 货到付款
await page.evaluate('document.querySelectorAll(".el-select__wrapper")[1].click()')
await page.wait_for_timeout(500)
await page.evaluate(
'(function(){'
'var o=document.querySelectorAll(".el-select-dropdown__item");'
'for(var i=0;i<o.length;i++)'
'{if(o[i].textContent.includes("货到付款"))o[i].click();}'
'})()'
)
# 开票要求 → 增值税专票
await page.evaluate(
'(function(){'
'var d=document.querySelectorAll(".c_check div");'
'for(var i=0;i<d.length;i++)'
'{if(d[i].textContent.includes("增值税专票"))d[i].click();}'
'})()'
)
# 报价方式 → 公开报价
await page.evaluate(
'(function(){'
'var a=document.querySelectorAll("*");'
'for(var i=0;i<a.length;i++)'
'{if(a[i].textContent.trim()==="公开报价"'
'&&a[i].children.length===0)a[i].click();}'
'})()'
)
async def submit_form(page):
"""点击「发起询价」提交,返回是否成功(无验证错误)"""
await page.evaluate(
"""() => {
var btn = document.querySelector(".btm_btn");
if (!btn) return;
for (var i=0; i<btn.children.length; i++) {
if (btn.children[i].textContent.trim() === "发起询价") {
btn.children[i].click();
return;
}
}
}"""
)
await page.wait_for_timeout(2000)
error_found = await page.evaluate(
"""() => {
var errs = document.querySelectorAll('.el-message--error, .el-form-item__error');
if (errs.length > 0) {
return Array.from(errs).map(e => e.textContent.trim()).join(' | ');
}
var invalid = document.querySelectorAll('.is-error .el-input__inner');
if (invalid.length > 0) {
return '表单验证失败';
}
return null;
}"""
)
if error_found:
return False
return True
async def publish(lead, headless=True):
"""主流程"""
async with async_playwright() as p:
browser = await p.chromium.launch(headless=headless)
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)
# 导航到 direv 首页
await page.goto(
"https://www.direv.com.cn/#/index",
wait_until="domcontentloaded",
)
await page.wait_for_timeout(5000)
# 点击「发布询价」(优先 button 精确匹配 → 兜底模糊匹配)
for el in await page.query_selector_all("button"):
try:
if ((await el.text_content()) or "").strip() == "发布询价":
await el.click()
break
except Exception:
pass
else:
for el in await page.query_selector_all("span, div, button"):
try:
if ((await el.text_content()) or "").strip() == "发布询价信息":
await el.click()
break
except Exception:
pass
await page.wait_for_timeout(8000)
# 等待表单加载
try:
await page.wait_for_selector(".c_title", timeout=15000)
except Exception:
print(" ⚠️ 表单未加载,跳过...")
await page.screenshot(path=f"/tmp/direv_err_{lead['title'][:10]}.png", full_page=True)
await ctx.close()
return False
# 填表
print(f" 填表: {lead['title'][:30]}...")
await fill_form(page, lead)
# 提交并检查错误
print(" 提交中...")
ok = await submit_form(page)
if not ok:
print(" ❌ 表单验证错误")
await page.screenshot(path=f"/tmp/direv_err_{lead['title'][:10]}.png", full_page=True)
await ctx.close()
return False
# 截图
path = f"/tmp/direv_{lead['title'][:10]}.png"
await page.screenshot(path=path, full_page=True)
# headed 模式:手动登录后保存登录态
if not headless and not os.path.exists(AUTH_FILE):
await ctx.storage_state(path=AUTH_FILE)
print(f" ✓ 登录态已保存 → {AUTH_FILE}")
print(f" ✓ 发布完成 → {path}")
await browser.close()
return True
# ============ CLI ============
if __name__ == "__main__":
headless = "--headed" not in sys.argv
if headless:
if not os.path.exists(AUTH_FILE):
print("❌ 登录态不存在,请先运行: python3 publish.py --headed")
sys.exit(1)
else:
if not os.path.exists(AUTH_FILE):
print("⚠️ 首次运行,请手动登录后关闭浏览器...")
print(f"模式: {'headless' if headless else 'headed (可见浏览器)'}")
ok = asyncio.run(publish(LEAD, headless=headless))
sys.exit(0 if ok else 1)