/** * Priority1 表单交互实现 — 与 form-logic-map.ts 字段定义一一对应 * form-logic-map 描述「填什么」;本文件描述「怎么点/怎么选」 * * 录制参考:.rpa/recordings/priority1-*.js * - Step1 FTL: #cb_st_ftl_sel(回退 #cb_st_ftl) * - Open Deck Type: select 选项如 48 Foot Flatbed / 48 Foot Step Deck * - 温控: #minimum-temperature / #maximum-temperature * - FTL 附加: #cb_as_ftl1 / #cb_as_ftl2 / #cb_as_ftl3 */ import type { Locator, Page } from "playwright"; const CLICK = { force: true, noWaitAfter: true as const }; const OPEN_DECK_OPTION_RE = /Foot Flatbed|Foot Step Deck|Removable Gooseneck|Double Drop|Conestoga|Hot Shot/i; const EXCLUDED_SELECT_ID_RE = /PickupLocation|DropoffLocation|freight-shipment|freight-package|freight-type/i; /** F04 人工录制:Open Deck Type 在 Feathery 误命名 select #PickupLocation3 */ export const OPEN_DECK_TYPE_SELECT_SELECTOR = "#PickupLocation3"; /** 逻辑子类型 → 页面 option 文本模糊匹配 */ export function normalizeOpenDeckSubtypeQuery(subtype: string): string { const s = subtype.trim(); if (/Foot Flatbed|Foot Step Deck|Removable Gooseneck|Double Drop|Conestoga|Hot Shot/i.test(s)) { return s; } const aliases: Record = { Flatbed: "Foot Flatbed", "Step Deck": "Foot Step Deck", "Goose Neck RGN": "Removable Gooseneck", Gooseneck: "Removable Gooseneck", }; for (const [k, v] of Object.entries(aliases)) { if (s === k) return v; } return s; } function optionMatchesSubtype(optionText: string, subtype: string): boolean { const opt = optionText.trim().toLowerCase(); const sub = subtype.trim().toLowerCase(); const normalized = normalizeOpenDeckSubtypeQuery(subtype).toLowerCase(); return ( opt === sub || opt.includes(sub) || sub.includes(opt) || opt.includes(normalized) || normalized.includes(opt) ); } export async function waitForOpenDeckSubtypeUi(page: Page): Promise { const started = Date.now(); while (Date.now() - started < 25_000) { const ready = await page.evaluate(function (args) { const optionRe = new RegExp(args.optionReSource, "i"); const excludeRe = new RegExp(args.excludeReSource, "i"); const container = document.querySelector("#container-mh"); if (!container) return false; const deckSel = container.querySelector("#PickupLocation3"); if (deckSel instanceof HTMLSelectElement && deckSel.options.length >= 2) return true; const selects = container.querySelectorAll("select"); for (let s = 0; s < selects.length; s += 1) { const el = selects[s]; let deckOptions = 0; for (let j = 0; j < el.options.length; j += 1) { if (optionRe.test(el.options[j].text.trim())) deckOptions += 1; } if (excludeRe.test(el.id) && deckOptions < 2) continue; if (deckOptions >= 2) return true; } return /Open Deck Type|Foot Flatbed|Foot Step Deck/i.test( container.textContent || "", ); }, { optionReSource: OPEN_DECK_OPTION_RE.source, excludeReSource: EXCLUDED_SELECT_ID_RE.source, }); if (ready) return; await page.waitForTimeout(400); } } export async function waitForFeatheryCheckbox( page: Page, selector: string, timeoutMs = 25_000, ): Promise { await page .locator(`#container-mh ${selector}`) .first() .waitFor({ state: "attached", timeout: timeoutMs }); await page.waitForTimeout(600); } /** Feathery 隐藏 checkbox — DOM 强制勾选并派发事件(避免 headless click 无效) */ export async function forceFeatheryCheckbox( page: Page, selector: string, ): Promise { return page.evaluate(function (sel) { const root = document.querySelector("#container-mh"); if (!root) return false; const cb = root.querySelector(sel); if (!(cb instanceof HTMLInputElement) || cb.type !== "checkbox") return false; if (!cb.checked) { cb.checked = true; cb.dispatchEvent(new Event("input", { bubbles: true })); cb.dispatchEvent(new Event("change", { bubbles: true })); cb.dispatchEvent(new Event("click", { bubbles: true })); } return cb.checked; }, selector); } export function freightClassOptionMatches( optionText: string, expected: string, ): boolean { const t = optionText.trim().toLowerCase(); const w = expected.trim().toLowerCase(); if (t === w || optionText.trim() === expected.trim()) return true; if (w === "not sure" && t.includes("not sure")) return true; return false; } /** form-logic-map → FTL_CONDITIONAL_BRANCHES.ftl_open_deck.openDeckSubtype */ export async function selectOpenDeckSubtype( page: Page, subtype: string, ): Promise<{ locator: Locator; selectedText: string }> { await page.waitForTimeout(1200); await waitForOpenDeckSubtypeUi(page); const query = normalizeOpenDeckSubtypeQuery(subtype); const deckLoc = page.locator(`#container-mh ${OPEN_DECK_TYPE_SELECT_SELECTOR}`).first(); if (await deckLoc.count()) { await deckLoc.selectOption({ label: query }).catch(() => deckLoc.selectOption({ label: subtype.trim() }), ); await page.waitForTimeout(400); const selectedText = await readSelectVisibleText(deckLoc); if (optionMatchesSubtype(selectedText, subtype)) { console.log( `[form-interactions] Open Deck Type → ${selectedText} (${OPEN_DECK_TYPE_SELECT_SELECTOR})`, ); return { locator: deckLoc, selectedText }; } } const picked = await page.evaluate( function (args) { const excludeRe = new RegExp(args.excludeReSource, "i"); const optionRe = new RegExp(args.optionReSource, "i"); const container = document.querySelector("#container-mh"); if (!container) return null; const selects = container.querySelectorAll("select"); for (let s = 0; s < selects.length; s += 1) { const sel = selects[s]; let deckOptions = 0; for (let j = 0; j < sel.options.length; j += 1) { if (optionRe.test(sel.options[j].text.trim())) deckOptions += 1; } if (excludeRe.test(sel.id) && deckOptions < 2) continue; for (let i = 0; i < sel.options.length; i += 1) { const opt = sel.options[i]; const optText = opt.text.trim().toLowerCase(); const needle = args.sub.trim().toLowerCase(); if (optText.indexOf(needle) < 0 && needle.indexOf(optText) < 0) continue; sel.selectedIndex = i; sel.value = opt.value; sel.dispatchEvent(new Event("input", { bubbles: true })); sel.dispatchEvent(new Event("change", { bubbles: true })); return { id: sel.id, text: opt.text.trim(), index: s }; } } return null; }, { sub: query, excludeReSource: EXCLUDED_SELECT_ID_RE.source, optionReSource: OPEN_DECK_OPTION_RE.source, }, ); if (picked) { console.log( `[form-interactions] Open Deck Type → ${picked.text} (#${picked.id || picked.index})`, ); const locator = picked.id ? page.locator(`#container-mh #${picked.id}`) : page.locator("#container-mh select").nth(picked.index); return { locator, selectedText: picked.text }; } throw new Error(`Open Deck Type 未找到选项: ${subtype}`); } /** form-logic-map → FTL_CONDITIONAL_BRANCHES.ftl_temp */ export async function fillTemperatureRange( page: Page, minF: string, maxF: string, ): Promise<{ min: Locator; max: Locator }> { const root = page.locator("#container-mh"); const min = root.locator("#minimum-temperature").first(); const max = root.locator("#maximum-temperature").first(); await min.waitFor({ state: "visible", timeout: 15_000 }); await min.fill(""); await min.pressSequentially(minF, { delay: 30 }); await min.evaluate(function (el) { el.dispatchEvent(new Event("input", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); }); await max.fill(""); await max.pressSequentially(maxF, { delay: 30 }); await max.evaluate(function (el) { el.dispatchEvent(new Event("input", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); }); return { min, max }; } /** Feathery 隐藏 checkbox 是否已勾选(避免 locator.isChecked 90s 超时) */ export async function isFeatheryCheckboxChecked( page: Page, selector: string, ): Promise { return page.evaluate(function (sel) { const root = document.querySelector("#container-mh"); if (!root) return false; const cb = root.querySelector(sel); return cb instanceof HTMLInputElement && cb.type === "checkbox" && cb.checked; }, selector); } /** Feathery 卡片黑底高亮 = 已选 */ export async function isFeatheryCardSelected( page: Page, label: string, ): Promise { return page.evaluate(function (labelText) { const nodes = document.querySelectorAll("#container-mh *"); for (let i = 0; i < nodes.length; i += 1) { const el = nodes[i]; const text = (el.textContent || "").trim(); if (!text.startsWith(labelText) || text.length > 220) continue; let cur: Element | null = el; for (let d = 0; d < 6 && cur; d += 1) { const bg = getComputedStyle(cur).backgroundColor; if (bg === "rgb(0, 0, 0)" || bg === "black") return true; cur = cur.parentElement; } } return false; }, label); } /** form-logic-map → FTL_ADDITIONAL_FIELDS */ export async function selectFtlAdditionalService( page: Page, selector: string, label: string, ): Promise { const root = page.locator("#container-mh"); await root .getByText(/Additional Services/i) .first() .scrollIntoViewIfNeeded() .catch(() => undefined); await page.waitForTimeout(400); const cb = root.locator(selector).first(); const labelRe = new RegExp(`^${label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i"); if ( (await isFeatheryCheckboxChecked(page, selector)) || (await isFeatheryCardSelected(page, label)) ) { return true; } /** 人工录制 F01:getByText("Full FTL") 点击 Feathery 卡片 */ const textHit = root.getByText(label, { exact: true }).first(); if ((await textHit.count()) > 0) { await textHit.scrollIntoViewIfNeeded().catch(() => undefined); await textHit.click(CLICK); await page.waitForTimeout(500); if (await isFeatheryCheckboxChecked(page, selector)) return true; if (await isFeatheryCardSelected(page, label)) return true; } for (let attempt = 0; attempt < 5; attempt += 1) { await forceFeatheryCheckbox(page, selector); if ((await cb.count()) > 0) { await cb.click(CLICK).catch(() => undefined); } await root.getByText(labelRe).first().click(CLICK).catch(() => undefined); await page.waitForTimeout(600); if (await isFeatheryCheckboxChecked(page, selector)) return true; if (await isFeatheryCardSelected(page, label)) return true; } return ( (await isFeatheryCheckboxChecked(page, selector)) || (await isFeatheryCardSelected(page, label)) ); } /** LTL Freight Class — 优先可见 #freight-type,按 option 文本匹配并返回实际操作的 locator */ export async function selectFreightClass( page: Page, freightClass: string, ): Promise { const root = page.locator("#container-mh"); await root .locator("#freight-type, #freight-class") .first() .waitFor({ state: "visible", timeout: 10_000 }); const picked = await page.evaluate(function (expected) { const container = document.querySelector("#container-mh"); if (!container) return null; const ids = ["freight-type", "freight-class"]; for (let idIdx = 0; idIdx < ids.length; idIdx += 1) { const el = container.querySelector( "#" + ids[idIdx], ) as HTMLSelectElement | null; if (!el) continue; const r = el.getBoundingClientRect(); if (r.width <= 0 || r.height <= 0) continue; for (let i = 0; i < el.options.length; i += 1) { const opt = el.options[i]; if (!opt) continue; const text = opt.text.trim(); const t = text.toLowerCase(); const w = String(expected).trim().toLowerCase(); let isMatch = t === w || text === String(expected).trim(); if (!isMatch && w === "not sure" && t.indexOf("not sure") >= 0) isMatch = true; if (!isMatch) continue; el.selectedIndex = i; el.value = opt.value; el.dispatchEvent(new Event("input", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); return { text: text, id: el.id }; } } return null; }, freightClass); const sel = picked?.id ? root.locator(`#${picked.id}`).first() : root.locator("#freight-type").first(); if (picked) { console.log(`[form-interactions] Freight Class → ${picked.text} (#${picked.id})`); } else { await sel.selectOption({ label: freightClass }).catch(() => sel.selectOption(freightClass)); } await page.waitForTimeout(400); let observed = await readSelectVisibleText(sel); if (!freightClassOptionMatches(observed, freightClass)) { await sel.selectOption({ label: freightClass }).catch(() => undefined); await page.waitForTimeout(400); observed = await readSelectVisibleText(sel); } if (!freightClassOptionMatches(observed, freightClass)) { throw new Error(`Freight Class 未能选中 "${freightClass}"(当前: "${observed}")`); } return sel; } /** LTL 附加服务 label 文本匹配(纯函数,供单测) */ export function ltlAccessorialLabelMatches( elementText: string, label: string, ): boolean { const text = elementText.replace(/\s+/g, " ").trim().toLowerCase(); const want = label.trim().toLowerCase(); if (!text || !want) return false; return text === want || text.includes(want); } /** canonical 标签 → Feathery 页面可见文案 */ export function ltlAccessorialDisplayText(label: string): string { if (label === "Limited Access Pickup" || label === "Limited Access Delivery") { return "Limited Access"; } return label; } /** 附加项属于提货段还是派送段 */ export function ltlAccessorialIsPickupSection(label: string): boolean { return /Pickup/i.test(label); } async function findLtlAccessorialCheckboxSelector( page: Page, label: string, ): Promise { const pageText = ltlAccessorialDisplayText(label); const wantPickup = ltlAccessorialIsPickupSection(label); return page.evaluate( function (args) { const container = document.querySelector("#container-mh"); if (!container) return null; const cbs = container.querySelectorAll('input[type="checkbox"]'); for (let i = 0; i < cbs.length; i += 1) { const cb = cbs[i]; if (!(cb instanceof HTMLInputElement)) continue; let labelText = ""; let node = cb.parentElement; for (let depth = 0; depth < 12 && node; depth += 1) { const spans = node.querySelectorAll("span"); for (let s = 0; s < spans.length; s += 1) { const t = (spans[s].textContent || "").replace(/\s+/g, " ").trim(); if (t.length > 2 && t.length < 50) { labelText = t; depth = 99; break; } } node = node.parentElement; } if (labelText !== args.pageText) continue; if (args.pageText === "Limited Access") { let limitedSeen = 0; for (let j = 0; j <= i; j += 1) { const prior = cbs[j]; if (!(prior instanceof HTMLInputElement)) continue; let priorText = ""; let priorNode = prior.parentElement; for (let depth = 0; depth < 12 && priorNode; depth += 1) { const spans = priorNode.querySelectorAll("span"); for (let s = 0; s < spans.length; s += 1) { const t = (spans[s].textContent || "").replace(/\s+/g, " ").trim(); if (t.length > 2 && t.length < 50) { priorText = t; depth = 99; break; } } priorNode = priorNode.parentElement; } if (priorText === "Limited Access") limitedSeen += 1; } const inPickup = limitedSeen === 1; if (inPickup !== args.wantPickup) continue; } return cb.id ? "#" + cb.id : null; } return null; }, { pageText, wantPickup }, ); } /** 读取 LTL 附加 checkbox 是否已勾选(Feathery #Checkbox* 隐藏 input) */ export async function isLtlAccessorialChecked( page: Page, label: string, ): Promise { const selector = await findLtlAccessorialCheckboxSelector(page, label); if (!selector) return false; return page.evaluate(function (sel) { const root = document.querySelector("#container-mh"); if (!root) return false; const cb = root.querySelector(sel); return Boolean(cb && cb instanceof HTMLInputElement && cb.type === "checkbox" && cb.checked); }, selector); } /** LTL / Expedited 附加 — Feathery checkbox(P08 实机:#Checkbox15 等 + span 文案) */ export async function toggleLtlAccessorial( page: Page, label: string, ): Promise { if (await isLtlAccessorialChecked(page, label)) return true; const selector = await findLtlAccessorialCheckboxSelector(page, label); if (selector) { await forceFeatheryCheckbox(page, selector); await page.waitForTimeout(300); if (await isLtlAccessorialChecked(page, label)) return true; } const root = page.locator("#container-mh"); const pageText = ltlAccessorialDisplayText(label); const section = ltlAccessorialIsPickupSection(label) ? root.filter({ hasText: /Pickup Services/i }).first() : root.filter({ hasText: /Delivery Services/i }).first(); const hit = section.getByText(pageText, { exact: true }).first(); if ((await hit.count()) > 0) { await hit.scrollIntoViewIfNeeded().catch(() => undefined); await hit.click(CLICK); await page.waitForTimeout(400); } return isLtlAccessorialChecked(page, label); } /** Expedited / FTL 内嵌 Expedited — Large Straight 可选 Liftgate */ export async function selectExpeditedLiftgate(page: Page): Promise { const root = page.locator("#container-mh"); const section = root.filter({ hasText: /附加服务|Additional Services/i }).last(); const lift = section.getByText(/^Liftgate$/i).first(); if ((await lift.count()) === 0) { const fallback = root.getByText(/^Liftgate$/i).first(); if ((await fallback.count()) === 0) return false; await fallback.click(CLICK); return true; } await lift.click(CLICK); await page.waitForTimeout(400); return true; } export async function readSelectVisibleText(locator: Locator): Promise { return locator.evaluate(function (el) { const sel = el as HTMLSelectElement; if (sel.tagName === "SELECT") { return sel.options[sel.selectedIndex]?.text?.trim() ?? sel.value; } return (el as HTMLElement).innerText?.trim() ?? ""; }); } export function openDeckSelectionOk(observed: string, expected: string): boolean { if (!observed) return false; if (/No roof or sides includes/i.test(observed)) return false; return ( optionMatchesSubtype(observed, expected) || /Foot Flatbed|Foot Step Deck|Removable Gooseneck|Double Drop|Conestoga|Hot Shot/i.test( observed, ) ); }