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.
297 lines
8.8 KiB
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);
|
|
});
|