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.

595 lines
17 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 公开询价页报价提取Feathery custom_request + 页面摘要) */
export type Priority1QuoteLine = {
rank: number;
carrier: string;
carrierCode: string;
totalUsd: number;
transitDays: number;
deliveryDate: string | null;
expirationDate: string | null;
serviceLevel: string;
quoteId: number | null;
caboUrl: string | null;
source: "feathery-summary" | "visible-dom" | "api-all" | "dom" | "ftl-calendar" | "legacy-tier";
};
type ApiRateQuote = {
id?: number;
carrierName?: string;
carrierCode?: string;
transitDays?: number;
deliveryDate?: string;
expirationDate?: string;
serviceLevel?: string;
serviceLevelDescription?: string;
rateQuoteDetail?: { total?: number };
};
function asNumber(v: unknown): number {
if (typeof v === "number" && Number.isFinite(v)) return v;
if (typeof v === "string") {
const n = Number(v.replace(/,/g, ""));
if (Number.isFinite(n)) return n;
}
return 0;
}
function parseCustomRequestPayload(body: unknown): Priority1QuoteLine[] {
if (!body || typeof body !== "object") return [];
const data = (body as { data?: { id?: number; rateQuotes?: ApiRateQuote[] } })
.data;
const list = data?.rateQuotes;
if (!Array.isArray(list)) return [];
return list
.map((q, idx) => ({
rank: idx + 1,
carrier: String(q.carrierName ?? "").trim(),
carrierCode: String(q.carrierCode ?? "").trim(),
totalUsd: asNumber(q.rateQuoteDetail?.total),
transitDays: asNumber(q.transitDays),
deliveryDate: q.deliveryDate ?? null,
expirationDate: q.expirationDate ?? null,
serviceLevel: String(
q.serviceLevelDescription ?? q.serviceLevel ?? "",
).trim(),
quoteId: typeof data?.id === "number" ? data.id : null,
caboUrl:
typeof data?.id === "number"
? `https://dashboard.priority1.com/ltl/quotes/details/${data.id}`
: null,
source: "api-all" as const,
}))
.filter((q) => q.carrier && q.totalUsd > 0);
}
function parseFeatherySummaryPayload(body: unknown): Priority1QuoteLine[] {
if (!body || typeof body !== "object") return [];
const o = body as Record<string, unknown>;
const out: Priority1QuoteLine[] = [];
const slots = [
{
prefix: "LTL_best_price_quote",
rank: 1,
},
{
prefix: "LTL_standard_rate_quote",
rank: 2,
},
{
prefix: "LTL_second_rate_quote",
rank: 3,
},
{
prefix: "LTL_expedited_quote",
rank: 4,
},
];
for (const slot of slots) {
const carrier = o[`${slot.prefix}_carrier`];
const cost = o[`${slot.prefix}_cost`];
if (!carrier || !cost) continue;
out.push({
rank: slot.rank,
carrier: String(carrier),
carrierCode: "",
totalUsd: asNumber(cost),
transitDays: asNumber(o[`${slot.prefix}_transit`]),
deliveryDate: o[`${slot.prefix}_del_date`]
? String(o[`${slot.prefix}_del_date`])
: null,
expirationDate: o[`${slot.prefix}_exp_date`]
? String(o[`${slot.prefix}_exp_date`])
: null,
serviceLevel: slot.prefix.includes("expedited")
? "Expedited"
: "Standard",
quoteId: typeof o.quote_id === "number" ? o.quote_id : null,
caboUrl:
typeof o.cabo_url === "string"
? o.cabo_url
: typeof o.quote_id === "number"
? `https://dashboard.priority1.com/ltl/quotes/details/${o.quote_id}`
: null,
source: "feathery-summary",
});
}
/** FTL 日历页 — quote_00 / quote_01 等槽位 */
const ftlSlots = Object.keys(o).filter((k) => /^quote_\d+$/i.test(k));
for (let i = 0; i < ftlSlots.length; i += 1) {
const key = ftlSlots[i]!;
const cost = o[key];
const n = asNumber(cost);
if (n <= 0 || String(cost).includes("{{")) continue;
out.push({
rank: out.length + 1,
carrier: "FTL 日历报价",
carrierCode: "",
totalUsd: n,
transitDays: 0,
deliveryDate: null,
expirationDate: null,
serviceLevel: "FTL",
quoteId: typeof o.quote_id === "number" ? o.quote_id : null,
caboUrl: typeof o.cabo_url === "string" ? o.cabo_url : null,
source: "feathery-summary",
});
}
return out;
}
/** FTL 报价页 — YOUR INSTANT QUOTE OPTIONS 日历表格(价格常无 $ 前缀) */
export function parseFtlCalendarQuotes(text: string): Priority1QuoteLine[] {
if (!/YOUR INSTANT QUOTE OPTIONS|INSTANT QUOTE OPTIONS/i.test(text)) {
return [];
}
const out: Priority1QuoteLine[] = [];
const pairRe =
/\b(?:SUN|MON|TUE|WED|THU|FRI|SAT)\b\s+(\d{1,2})\s+(\d{4,5}\.\d{2})/gi;
let pair: RegExpExecArray | null;
while ((pair = pairRe.exec(text)) !== null) {
const day = Number(pair[1]);
const totalUsd = asNumber(pair[2]);
if (!Number.isFinite(day) || day < 1 || day > 31) continue;
if (totalUsd < 200 || totalUsd > 100_000) continue;
out.push({
rank: out.length + 1,
carrier: "FTL 日历报价",
carrierCode: "",
totalUsd,
transitDays: 0,
deliveryDate: `DAY:${day}`,
expirationDate: null,
serviceLevel: "FTL Calendar",
quoteId: null,
caboUrl: null,
source: "ftl-calendar" as const,
});
}
if (out.length > 0) {
return out;
}
const prices: number[] = [];
const re = /(?<![.\d])(\d{4,5}\.\d{2})(?![.\d])/g;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
const n = asNumber(m[1]);
if (n >= 200 && n <= 100_000) {
prices.push(n);
}
}
const unique = [...new Set(prices)].sort((a, b) => a - b);
return unique.map((totalUsd, idx) => ({
rank: idx + 1,
carrier: "FTL 日历报价",
carrierCode: "",
totalUsd,
transitDays: 0,
deliveryDate: null,
expirationDate: null,
serviceLevel: "FTL Calendar",
quoteId: null,
caboUrl: null,
source: "ftl-calendar" as const,
}));
}
/** 站点无即时报价、需销售人工确认时的标准返回文案 */
export const PRIORITY1_MANUAL_FOLLOWUP_MESSAGE =
"表单已提交,站点判定需人工确认细节后报价";
export type Priority1QuoteOutcome = "instant_quote" | "manual_followup" | "no_result";
/** FTL 温控 / Expedited 等 — 「MORE INFORMATION NEEDED」终态无金额 */
export function isPriority1ManualFollowupPage(domText: string): boolean {
return (
/MORE\s+INFORMATION\s+NEEDED/i.test(domText) &&
/freight\s+specialist|Confirm\s+Details\s+&\s+Lock\s+In\s+Your\s+Rate|REQUEST\s+NEW\s+QUOTE/i.test(
domText,
)
);
}
export function resolvePriority1SubmitOutcome(
bodies: string[],
domText: string,
): {
outcome: Priority1QuoteOutcome;
quotes: Priority1QuoteLine[];
message?: string;
} {
const quotes = extractPriority1VisibleQuotes(bodies, domText);
if (quotes.length > 0) {
return { outcome: "instant_quote", quotes };
}
if (isPriority1ManualFollowupPage(domText)) {
return {
outcome: "manual_followup",
quotes: [],
message: PRIORITY1_MANUAL_FOLLOWUP_MESSAGE,
};
}
return { outcome: "no_result", quotes: [] };
}
/** 页面是否已进入报价结果LTL 卡片 / FTL 日历 / 含 $ 金额) */
export function isPriority1QuotePageReady(domText: string): boolean {
if (/\$[\d,]+\.\d{2}/.test(domText)) return true;
if (/QUOTE\s+\d+/i.test(domText) && /\$[\d,]+\.\d{2}/.test(domText)) return true;
if (parseFtlCalendarQuotes(domText).length > 0) return true;
if (
/YOUR INSTANT QUOTE OPTIONS|INSTANT QUOTE OPTIONS|REQUEST A NEW QUOTE/i.test(
domText,
) &&
/\b\d{4,5}\.\d{2}\b/.test(domText)
) {
return true;
}
return false;
}
function parseUsDashOrSlashDate(raw: string | null | undefined): string | null {
if (!raw) return null;
const s = raw.trim();
const dash = s.match(/^(\d{2})-(\d{2})-(\d{4})$/);
if (dash) return `${dash[1]}/${dash[2]}/${dash[3]}`;
const slash = s.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
if (slash) return s;
const iso = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (iso) return `${iso[2]}/${iso[3]}/${iso[1]}`;
return s;
}
const UNRESOLVED_CARRIER_RE = /未解析承运商|\(页面/;
function isUnresolvedCarrier(carrier: string | undefined | null): boolean {
const c = String(carrier ?? "").trim();
return !c || UNRESOLVED_CARRIER_RE.test(c);
}
function isLtlFeatherySummaryLine(line: Priority1QuoteLine): boolean {
return (
line.source === "feathery-summary" &&
line.carrier !== "FTL 日历报价" &&
!/^quote_\d+$/i.test(line.serviceLevel) &&
line.serviceLevel !== "FTL"
);
}
function pickOfficialUsd(
summaryUsd: number,
domUsd: number,
domCarrierUnresolved: boolean,
): number {
if (summaryUsd <= 0) return domUsd;
if (domUsd <= 0) return summaryUsd;
const drift = Math.abs(summaryUsd - domUsd) / summaryUsd;
if (drift <= 0.02) return domUsd;
return domCarrierUnresolved ? summaryUsd : domUsd;
}
function parseCarrierFromQuoteBlock(block: string): string {
const afterQuote = block.match(
/QUOTE\s+\d+\s+([A-Za-z][A-Za-z0-9\s&.'-]{1,48}?)\s+\$/i,
);
if (afterQuote?.[1]) {
return afterQuote[1].replace(/\s+/g, " ").trim();
}
const beforeTransit = block.match(
/STANDARD\s+LTL\s+(.+?)\s+Transit:/i,
);
if (beforeTransit?.[1]) {
const c = beforeTransit[1].replace(/\s+/g, " ").trim();
if (c && !/^QUOTE\s+\d/i.test(c)) return c;
}
const inline = block.match(
/STANDARD\s+LTL\s+(.+?)\s+Quote Expiration Date/i,
);
if (inline?.[1]) {
const c = inline[1].replace(/\s+/g, " ").trim();
if (c && !/^QUOTE\s+\d/i.test(c)) return c;
}
const lines = block.split(/\n/).map((l) => l.trim()).filter(Boolean);
for (let i = 0; i < lines.length; i += 1) {
if (!/^STANDARD\s+LTL/i.test(lines[i]!)) continue;
for (let j = i + 1; j < lines.length; j += 1) {
const line = lines[j]!;
if (
/^(Transit:|Quote Expiration|Estimated Delivery|\$|QUOTE\s+\d+)/i.test(
line,
)
) {
break;
}
if (line.length >= 2 && !/^STANDARD/i.test(line)) {
return line.replace(/\s+/g, " ").trim();
}
}
}
return "";
}
function extractPriceFromQuoteBlock(block: string): number {
const nearQuote = block.match(
/QUOTE\s+\d+[\s\S]{0,120}?\$\s*([\d,]+\.\d{2})/i,
);
if (nearQuote?.[1]) return asNumber(nearQuote[1]);
const prices = [...block.matchAll(/\$\s*([\d,]+\.\d{2})/g)].map((m) =>
asNumber(m[1]),
);
return prices.find((p) => p > 0) ?? 0;
}
function mergeLtlSummaryWithDomCards(
summary: Priority1QuoteLine[],
domCards: Priority1QuoteLine[],
): Priority1QuoteLine[] {
const ltlSummary = summary
.filter(isLtlFeatherySummaryLine)
.sort((a, b) => a.rank - b.rank);
if (ltlSummary.length === 0) return [];
const sortedDom = [...domCards].sort((a, b) => a.rank - b.rank);
return ltlSummary.map((s, idx) => {
const dom = sortedDom[idx];
if (!dom) return s;
return {
...s,
rank: dom.rank,
transitDays: dom.transitDays > 0 ? dom.transitDays : s.transitDays,
deliveryDate: dom.deliveryDate ?? s.deliveryDate,
expirationDate: dom.expirationDate ?? s.expirationDate,
carrierCode: s.carrierCode || dom.carrierCode,
source: "feathery-summary",
};
});
}
function enrichDomCardsFromNetwork(
cards: Priority1QuoteLine[],
bodies: string[],
): Priority1QuoteLine[] {
if (cards.length === 0) return cards;
const summary = extractFeatherySummaryQuotes(bodies)
.filter(isLtlFeatherySummaryLine)
.sort((a, b) => a.rank - b.rank);
const api = extractPriority1AllApiQuotes(bodies);
const sortedCards = [...cards].sort((a, b) => a.rank - b.rank);
return sortedCards.map((card, idx) => {
const unresolved = isUnresolvedCarrier(card.carrier);
const match =
summary.find((p) => p.rank === card.rank) ??
summary[idx] ??
(!unresolved
? api.find(
(p) =>
p.carrier.toLowerCase() === card.carrier.toLowerCase(),
)
: undefined) ??
api[idx];
if (!match) return card;
return {
...card,
carrier: unresolved ? match.carrier : card.carrier,
carrierCode: card.carrierCode || match.carrierCode,
totalUsd: pickOfficialUsd(match.totalUsd, card.totalUsd, unresolved),
deliveryDate: card.deliveryDate ?? match.deliveryDate,
expirationDate: card.expirationDate ?? match.expirationDate,
transitDays: card.transitDays > 0 ? card.transitDays : match.transitDays,
quoteId: card.quoteId ?? match.quoteId,
caboUrl: card.caboUrl ?? match.caboUrl,
};
});
}
function parseVisibleQuoteCardsDom(text: string): Priority1QuoteLine[] {
const out: Priority1QuoteLine[] = [];
const blocks = text.split(/(?=QUOTE\s+\d+)/i).filter((b) => /QUOTE\s+\d+/i.test(b));
for (const block of blocks) {
const rankM = block.match(/QUOTE\s+(\d+)/i);
const totalUsd = extractPriceFromQuoteBlock(block);
if (!rankM || totalUsd <= 0) continue;
const transitM = block.match(/Transit:\s*(\d+)\s+Business\s+Days?/i);
const expM = block.match(
/Quote Expiration Date:\s*(\d{2}[-/]\d{2}[-/]\d{4})/i,
);
const delM = block.match(
/Estimated Delivery Date:\s*(\d{2}[-/]\d{2}[-/]\d{4})/i,
);
const carrier = parseCarrierFromQuoteBlock(block);
out.push({
rank: Number(rankM[1]),
carrier: carrier || "(页面未解析承运商)",
carrierCode: "",
totalUsd,
transitDays: transitM ? Number(transitM[1]) : 0,
deliveryDate: parseUsDashOrSlashDate(delM?.[1]),
expirationDate: parseUsDashOrSlashDate(expM?.[1]),
serviceLevel: "STANDARD LTL",
quoteId: null,
caboUrl: null,
source: "visible-dom",
});
}
return out.sort((a, b) => a.rank - b.rank);
}
function parseDomTextQuotes(text: string): Priority1QuoteLine[] {
const prices = [...text.matchAll(/\$([\d,]+\.\d{2})/g)].map((m) =>
asNumber(m[1]),
);
const unique = [...new Set(prices)].filter((p) => p > 0).sort((a, b) => a - b);
return unique.map((totalUsd, idx) => ({
rank: idx + 1,
carrier: "(页面未解析承运商)",
carrierCode: "",
totalUsd,
transitDays: 0,
deliveryDate: null,
expirationDate: null,
serviceLevel: "",
quoteId: null,
caboUrl: null,
source: "dom",
}));
}
export function extractPriority1AllApiQuotes(
bodies: string[],
): Priority1QuoteLine[] {
let apiQuotes: Priority1QuoteLine[] = [];
for (const raw of bodies) {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
continue;
}
const fromApi = parseCustomRequestPayload(parsed);
if (fromApi.length > apiQuotes.length) {
apiQuotes = fromApi;
}
}
return dedupeQuotes(apiQuotes);
}
function extractFeatherySummaryQuotes(bodies: string[]): Priority1QuoteLine[] {
const summaryQuotes: Priority1QuoteLine[] = [];
for (const raw of bodies) {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
continue;
}
summaryQuotes.push(...parseFeatherySummaryPayload(parsed));
}
return dedupeQuotes(summaryQuotes);
}
/** 页面用户可见报价:可见 QUOTE 卡片 DOM > 摘要 > FTL 日历 > 全量 API */
export function extractPriority1VisibleQuotes(
bodies: string[],
domText = "",
): Priority1QuoteLine[] {
const domCards = parseVisibleQuoteCardsDom(domText);
const summary = extractFeatherySummaryQuotes(bodies);
const ltlSummary = summary.filter(isLtlFeatherySummaryLine);
if (ltlSummary.length > 0) {
const merged = mergeLtlSummaryWithDomCards(ltlSummary, domCards);
if (merged.length > 0) {
return merged;
}
}
if (domCards.length > 0) {
return enrichDomCardsFromNetwork(domCards, bodies);
}
if (summary.length > 0) {
return summary;
}
const ftlCalendar = parseFtlCalendarQuotes(domText);
if (ftlCalendar.length > 0) {
return ftlCalendar;
}
return extractPriority1AllApiQuotes(bodies).slice(0, 4).map((line, idx) => ({
...line,
rank: idx + 1,
}));
}
export function extractPriority1QuotesFromNetworkBodies(
bodies: string[],
): Priority1QuoteLine[] {
return extractPriority1VisibleQuotes(bodies);
}
export function extractPriority1QuotesFromDomText(text: string): Priority1QuoteLine[] {
const cards = parseVisibleQuoteCardsDom(text);
if (cards.length > 0) return cards;
const ftl = parseFtlCalendarQuotes(text);
if (ftl.length > 0) return ftl;
return parseDomTextQuotes(text);
}
export function resolvePriority1QuoteBundles(
bodies: string[],
domText = "",
): { visible: Priority1QuoteLine[]; allApi: Priority1QuoteLine[] } {
return {
visible: extractPriority1VisibleQuotes(bodies, domText),
allApi: extractPriority1AllApiQuotes(bodies),
};
}
function dedupeQuotes(lines: Priority1QuoteLine[]): Priority1QuoteLine[] {
const seen = new Set<string>();
const out: Priority1QuoteLine[] = [];
for (const line of lines) {
const key = `${line.carrier}|${line.totalUsd}|${line.transitDays}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ ...line, rank: out.length + 1 });
}
return out;
}
export function formatPriority1QuoteOutcomeMessage(
outcome: Priority1QuoteOutcome,
message?: string,
): string {
if (outcome === "manual_followup") {
return message ?? PRIORITY1_MANUAL_FOLLOWUP_MESSAGE;
}
if (outcome === "instant_quote") return "(即时报价)";
return "(无报价结果)";
}
export function formatPriority1QuotesTable(lines: Priority1QuoteLine[]): string {
if (!lines.length) return "(无报价)";
const rows = lines.map(
(q) =>
`${String(q.rank).padStart(2)}. ${q.carrier.padEnd(22)} $${q.totalUsd.toFixed(2).padStart(9)} ${q.transitDays}天 [${q.source}]`,
);
return rows.join("\n");
}