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