You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

541 lines
19 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* 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<string, string> = {
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<void> {
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<void> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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;
}
/** 人工录制 F01getByText("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<Locator> {
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<string | null> {
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<boolean> {
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 checkboxP08 实机:#Checkbox15 等 + span 文案) */
export async function toggleLtlAccessorial(
page: Page,
label: string,
): Promise<boolean> {
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<boolean> {
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<string> {
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,
)
);
}