#!/usr/bin/env tsx /** * 探测 Priority1 三种 Shipment Type 的 Step 2 表单差异 * npx tsx scripts/probe-priority1-shipment-types.ts */ import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { chromium, type Page } from "playwright"; const ENTRY = "https://www.priority1.com/instant-freight-quotes/#New%20Step%201"; const CLICK = { noWaitAfter: true as const }; type ShipmentProbe = { id: string; label: string; checkboxSelector: string; hashFallback?: string; }; const TYPES: ShipmentProbe[] = [ { id: "ltl", label: "Less Than Truckload", checkboxSelector: "#cb_st_ltl", hashFallback: "#V4.1%20Step%202%20-%20LTL", }, { id: "ftl", label: "Full Truckload", checkboxSelector: "#cb_st_ftl", hashFallback: "#V4.1%20Step%202%20-%20FTL", }, { id: "expedited", label: "Expedited", checkboxSelector: "#cb_st_expedited", hashFallback: "#V4.1%20Step%202%20-%20Expedited", }, ]; async function discoverCheckboxes(page: Page): Promise { return page.evaluate(() => { const out: string[] = []; const cbs = document.querySelectorAll( '#container-mh input[type="checkbox"], #container-mh input[type="radio"]', ); for (let i = 0; i < cbs.length; i += 1) { const el = cbs[i] as HTMLInputElement; const id = el.id || `(no-id-${i})`; const label = el.labels?.[0]?.textContent?.replace(/\s+/g, " ").trim().slice(0, 80) || el.getAttribute("aria-label") || ""; out.push(`${id} | checked=${el.checked} | ${label}`); } return out; }); } async function inventoryStep2(page: Page) { return page.evaluate(() => { const root = document.querySelector("#container-mh"); if (!root) return { title: "", fields: [] as Array> }; const title = root.querySelector("h1,h2,h3")?.textContent?.replace(/\s+/g, " ").trim() || document.title; const fields: Array> = []; const inputs = root.querySelectorAll("input, select, textarea"); for (let i = 0; i < inputs.length; i += 1) { const el = inputs[i] as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; const r = el.getBoundingClientRect(); if (r.width <= 0 || r.height <= 0) continue; let label = ""; if (el.id) { const lbl = root.querySelector(`label[for="${el.id}"]`); if (lbl) label = lbl.textContent?.replace(/\s+/g, " ").trim() || ""; } if (!label && el.labels?.[0]) { label = el.labels[0].textContent?.replace(/\s+/g, " ").trim() || ""; } let options = ""; if (el.tagName === "SELECT") { const sel = el as HTMLSelectElement; options = [...sel.options] .map((o) => o.text.trim()) .filter(Boolean) .slice(0, 12) .join(" | "); } fields.push({ tag: el.tagName.toLowerCase(), type: (el as HTMLInputElement).type || "", id: el.id || "", name: el.getAttribute("name") || "", label: label.slice(0, 120), placeholder: el.getAttribute("placeholder") || "", value: String( el.tagName === "SELECT" ? (el as HTMLSelectElement).selectedOptions[0]?.text || "" : (el as HTMLInputElement).value || "", ).slice(0, 80), options, }); } const cards = [...root.querySelectorAll("*")].filter((el) => { const t = (el.textContent || "").replace(/\s+/g, " ").trim(); return ( t.length > 10 && t.length < 200 && /Dry Van|Open Deck|Temperature|Sprinter|Trailer|Partial PTL|Team Drivers/i.test(t) ); }); const cardTexts = [...new Set(cards.map((el) => el.textContent!.replace(/\s+/g, " ").trim()))] .slice(0, 20); return { title, fields, cardTexts }; }); } async function fillStep1(page: Page): Promise { await page.locator("#container-mh #origin-zip").fill("32801"); await page.locator("#container-mh #destination-zip").fill("28202"); await page.locator("#container-mh #freight-shipment").selectOption("One Time Shipment"); await page.locator("#container-mh #pickup-date").fill("07/14/2026"); await page.locator("#container-mh #business-email").fill("niqijiti542@gmail.com"); const phone = page.locator("#container-mh #client-phone"); await phone.click(CLICK); await phone.fill(""); await phone.pressSequentially("7536407420", { delay: 20 }); } async function selectShipmentType( page: Page, probe: ShipmentProbe, ): Promise { const cb = page.locator(`#container-mh ${probe.checkboxSelector}`); if ((await cb.count()) === 0) { await page .locator("#container-mh") .getByText(new RegExp(probe.label, "i")) .first() .click(CLICK) .catch(() => undefined); } else { await cb.click({ force: true, ...CLICK }); } await page.waitForTimeout(600); await page .locator("#container-mh") .getByText(new RegExp(`^${probe.label.split(" ")[0]}`, "i")) .first() .click(CLICK) .catch(() => undefined); await page.waitForTimeout(500); return page.evaluate((sel) => { const cb = document.querySelector(sel) as HTMLInputElement | null; return Boolean(cb?.checked); }, `#container-mh ${probe.checkboxSelector}`); } async function goToStep2( page: Page, probe: ShipmentProbe, ): Promise<"submit" | "hash"> { const btn = page .locator("#container-mh") .getByRole("button", { name: /GET INSTANT QUOTES/i }) .first(); await btn.click(CLICK); await page.waitForTimeout(2000); await btn.click(CLICK).catch(() => undefined); const appeared = await page .waitForFunction( () => { const root = document.querySelector("#container-mh"); if (!root) return false; const text = root.textContent || ""; return ( /Step\s*2\s*of\s*2/i.test(text) && !/Pickup ZIP Code/i.test(text.slice(0, 500)) ); }, { timeout: 25_000 }, ) .then(() => true) .catch(() => false); if (appeared) return "submit"; if (probe.hashFallback) { await page.goto( `https://www.priority1.com/instant-freight-quotes/${probe.hashFallback}`, { waitUntil: "domcontentloaded" }, ); await page.waitForTimeout(1500); return "hash"; } return "hash"; } async function main(): Promise { const outDir = join(process.cwd(), ".rpa", "priority1-shipment-probe"); mkdirSync(outDir, { recursive: true }); const browser = await chromium.launch({ headless: process.env.RPA_HEADLESS !== "false", channel: (process.env.RPA_BROWSER_CHANNEL?.trim() || "chrome") as "chrome", }); const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); const report: Record = { probedAt: new Date().toISOString(), types: [] as unknown[], }; await page.goto(ENTRY, { waitUntil: "domcontentloaded" }); await page.locator("#container-mh").waitFor({ state: "visible", timeout: 60_000 }); report.step1Checkboxes = await discoverCheckboxes(page); for (const probe of TYPES) { console.log(`\n[probe] ▶ ${probe.id} — ${probe.label}`); await page.goto(ENTRY, { waitUntil: "domcontentloaded" }); await page.locator("#container-mh").waitFor({ state: "visible" }); await page.waitForTimeout(800); const selected = await selectShipmentType(page, probe); await fillStep1(page); await page.screenshot({ path: join(outDir, `${probe.id}-step1-filled.png`), fullPage: true, }); const nav = await goToStep2(page, probe); await page.waitForTimeout(1000); const inv = await inventoryStep2(page); await page.screenshot({ path: join(outDir, `${probe.id}-step2-full.png`), fullPage: true, }); const entry = { id: probe.id, label: probe.label, checkboxSelector: probe.checkboxSelector, selected, navigatedVia: nav, finalUrl: page.url(), step2Title: inv.title, fieldCount: inv.fields.length, fields: inv.fields, optionCards: inv.cardTexts, }; (report.types as unknown[]).push(entry); writeFileSync( join(outDir, `${probe.id}-step2-fields.json`), `${JSON.stringify(entry, null, 2)}\n`, "utf8", ); console.log(` selected=${selected} nav=${nav} fields=${inv.fields.length}`); console.log(` title: ${inv.title}`); for (const f of inv.fields.slice(0, 15)) { console.log(` - [${f.tag}/${f.type}] #${f.id} ${f.label || f.placeholder}`); } if (inv.fields.length > 15) { console.log(` ... +${inv.fields.length - 15} more`); } } writeFileSync( join(outDir, "shipment-types-report.json"), `${JSON.stringify(report, null, 2)}\n`, "utf8", ); console.log(`\n[probe] 报告: ${join(outDir, "shipment-types-report.json")}`); await browser.close(); } main().catch((e) => { console.error(e); process.exit(1); });