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.
chajia/scripts/probe-priority1-shipment-ty...

297 lines
8.8 KiB

#!/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<string[]> {
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<Record<string, string>> };
const title =
root.querySelector("h1,h2,h3")?.textContent?.replace(/\s+/g, " ").trim() ||
document.title;
const fields: Array<Record<string, string>> = [];
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<void> {
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<boolean> {
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<void> {
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<string, unknown> = {
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);
});