feat(pl24): wire Ford/Volvo VIN-decode flow end-to-end via json-vin endpoints
VIN-decoded Ford P/T and Volvo legacy vehicles used to land on an empty category page: the .action HTML the decoder scraped only carried header breadcrumbs (Portal / Model seçimi / VIN). The real catalog hangs off three JSON endpoints that the partslink24 UI calls in the background once a VIN session is established. None of them need mode/upds/JSESSIONID beyond the standard PL24TOKEN cookie. Plumb the whole chain so a user who decoded a VIN sees real Turkish part categories and OEM part numbers in the user-vehicle flow: json-vin-main-group.action → real top-level groups (8 for Mondeo) json-vin-sub-group.action → 58 leaf subgroups (filters subheaders) vin-image-board.action → BOM table with pncHierCode + jsonUrl json-vin-bom-detail.action → final OEM partno entries (per variant) Schema image fetch reuses the existing image-ticket extractor since the ticket URL lives in the same jsIlluData payload as Hyundai/Opel/Volvo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -160,6 +160,17 @@ export class PL24FordLegacyService {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Ford/Volvo VIN flow: json-vin-sub-group.action → { subgroups: [{caption, id, url, subheader}] }
|
||||
// Leaf rows have subheader=false and url="vin-image-board.action?bomId=...".
|
||||
if (linkPath.includes("json-vin-sub-group.action")) {
|
||||
const basePath = linkPath.substring(0, linkPath.lastIndexOf("/") + 1);
|
||||
const groups = this.parseFordVinSubGroupsJson(html, basePath);
|
||||
if (groups.length > 0) {
|
||||
await this.redis.setJson(cacheKey, groups, 86400);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
// Nissan/Infiniti/Opel: json-sub-group.action
|
||||
if (linkPath.includes("json-sub-group.action")) {
|
||||
const basePath = linkPath.substring(0, linkPath.lastIndexOf("/") + 1);
|
||||
@@ -300,7 +311,14 @@ export class PL24FordLegacyService {
|
||||
}
|
||||
|
||||
let parts: PL24Part[];
|
||||
if (isImageBoard) {
|
||||
const isVinImageBoard = linkPath.includes("vin-image-board.action");
|
||||
if (isVinImageBoard) {
|
||||
// Ford/Volvo VIN flow: rows have pncHierCode + jsonUrl pointing to
|
||||
// json-vin-bom-detail.action. Each detail JSON can return MULTIPLE
|
||||
// valid partno entries (e.g. left/right variant) — keep all of them.
|
||||
const pncRows = this.parseFordVinPncRows(html);
|
||||
parts = await this.fetchFordVinBomParts(pncRows, serviceName);
|
||||
} else if (isImageBoard) {
|
||||
// Try Hyundai/Kia/Nissan format: pnc= rows with json-bom-detail.action
|
||||
const pncRows = this.parseHyundaiPncRows(html);
|
||||
if (pncRows.length > 0) {
|
||||
@@ -318,7 +336,7 @@ export class PL24FordLegacyService {
|
||||
} else {
|
||||
parts = this.parsePartsFromHtml(html);
|
||||
}
|
||||
if (parts.length === 0 && !isImageBoard) {
|
||||
if (parts.length === 0 && !isImageBoard && !isVinImageBoard) {
|
||||
parts = this.parsePartsFromHtml(html);
|
||||
}
|
||||
|
||||
@@ -332,7 +350,7 @@ export class PL24FordLegacyService {
|
||||
let schemaHeight: number | undefined;
|
||||
let schemaHotspots: any[] | undefined;
|
||||
|
||||
if (isImageBoard) {
|
||||
if (isImageBoard || isVinImageBoard) {
|
||||
const ticketUrl = this.extractPsaImageTicketUrl(html);
|
||||
if (ticketUrl) {
|
||||
try {
|
||||
@@ -2334,6 +2352,111 @@ export class PL24FordLegacyService {
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Ford/Volvo vin-image-board.action HTML rows.
|
||||
* Each tc-data-row carries:
|
||||
* pncHierCode="GDA010151" → unique code per BOM position
|
||||
* pnc="1" → display position number (hotspot)
|
||||
* jsonUrl=".../json-vin-bom-detail.action?...pncHierCode=..."
|
||||
* <td class="caption ...">İç Panel Komplesi - Ön Çamurluk</td>
|
||||
*
|
||||
* Returns rows to be expanded via fetchFordVinBomParts.
|
||||
*/
|
||||
private parseFordVinPncRows(
|
||||
html: string,
|
||||
): { pncHierCode: string; pos: string; caption: string; jsonUrl: string }[] {
|
||||
const rows: { pncHierCode: string; pos: string; caption: string; jsonUrl: string }[] = [];
|
||||
const trParts = html.split(/<tr[\s>]/);
|
||||
for (const segment of trParts) {
|
||||
if (!segment.includes("tc-data-row")) continue;
|
||||
const jsonUrlMatch = segment.match(/\bjsonUrl="([^"]+json-vin-bom-detail[^"]+)"/);
|
||||
if (!jsonUrlMatch) continue;
|
||||
const jsonUrl = jsonUrlMatch[1].replace(/&/g, "&");
|
||||
|
||||
const pncHierMatch =
|
||||
segment.match(/\bpncHierCode="([^"]+)"/) ?? jsonUrl.match(/[?&]pncHierCode=([^&]+)/);
|
||||
const pncHierCode = pncHierMatch?.[1] ?? "";
|
||||
if (!pncHierCode) continue;
|
||||
|
||||
const posMatch = segment.match(/\bpnc="([^"]*)"/);
|
||||
const pos = posMatch?.[1] ?? "";
|
||||
|
||||
const captionTd = segment.match(/class="caption[^"]*"[^>]*>([\s\S]*?)<\/td>/);
|
||||
const caption = captionTd
|
||||
? captionTd[1]
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.trim()
|
||||
: "";
|
||||
|
||||
rows.push({ pncHierCode, pos, caption, jsonUrl });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand each BOM row into one or more parts by calling
|
||||
* json-vin-bom-detail.action. The detail JSON returns multiple variants per
|
||||
* position (e.g. left/right, model-year cut-offs). We keep every detail
|
||||
* whose partno is set — variants like {info:"Sağ"} / {info:"Sol"} are
|
||||
* separate orderable parts and should both reach the user. Disabled rows
|
||||
* (valid:"false") are surfaced as unavailable so the table layout stays
|
||||
* stable.
|
||||
*/
|
||||
private async fetchFordVinBomParts(
|
||||
rows: { pncHierCode: string; pos: string; caption: string; jsonUrl: string }[],
|
||||
serviceName: string,
|
||||
): Promise<PL24Part[]> {
|
||||
const parts: PL24Part[] = [];
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const jsonStr = await this.fetchP4Page(row.jsonUrl, serviceName, true);
|
||||
if (!jsonStr) continue;
|
||||
const data = JSON.parse(jsonStr) as {
|
||||
details?: {
|
||||
partno?: string;
|
||||
caption?: string;
|
||||
qty?: string | number;
|
||||
info?: string;
|
||||
finis?: string;
|
||||
valid?: string | boolean;
|
||||
gray?: boolean;
|
||||
subheader?: boolean;
|
||||
}[];
|
||||
};
|
||||
const details = (data.details ?? []).filter((d) => !d.subheader);
|
||||
if (details.length === 0) continue;
|
||||
|
||||
for (const d of details) {
|
||||
if (!d.partno) continue;
|
||||
const valid = d.valid === true || d.valid === "true";
|
||||
const qty =
|
||||
typeof d.qty === "number"
|
||||
? d.qty
|
||||
: Number.parseFloat(String(d.qty ?? "1").replace(",", ".")) || 1;
|
||||
const captionRaw = d.caption || row.caption;
|
||||
const name = captionRaw.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
|
||||
parts.push({
|
||||
id: `${row.pncHierCode}:${d.partno}`,
|
||||
oemCode: d.partno,
|
||||
name,
|
||||
positionCode: row.pos,
|
||||
hotspotId: row.pos,
|
||||
quantity: qty,
|
||||
remark: d.info?.trim() || undefined,
|
||||
unavailable: !valid,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.warn(
|
||||
`FordVinBomParts pncHierCode=${row.pncHierCode}: ${(e as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Opel image-board.action parts — uses gmNo= attribute format.
|
||||
* Rows have: gmNo="11611067", caption="CIVATA", hotspot="12", quantity in <td>
|
||||
@@ -2463,6 +2586,51 @@ export class PL24FordLegacyService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Ford/Volvo json-vin-sub-group.action response.
|
||||
* Shape: { subgroups: [{ caption, id, subheader, url, jsonUrl }] }
|
||||
* Only rows with subheader=false and a leaf url (vin-image-board.action) are
|
||||
* navigable. Header rows (subheader=true) just visually group the leaves.
|
||||
*/
|
||||
private parseFordVinSubGroupsJson(jsonStr: string, basePath: string): PL24MainGroup[] {
|
||||
try {
|
||||
const data = JSON.parse(jsonStr) as {
|
||||
subgroups?: {
|
||||
caption?: string;
|
||||
id?: string;
|
||||
subheader?: boolean;
|
||||
gray?: boolean;
|
||||
url?: string | null;
|
||||
jsonUrl?: string | null;
|
||||
}[];
|
||||
};
|
||||
if (!data.subgroups) return [];
|
||||
return data.subgroups
|
||||
.filter((s) => !s.subheader && !s.gray && !!(s.url || s.jsonUrl) && !!s.caption)
|
||||
.map((s, idx) => {
|
||||
const cleanName = (s.caption || "")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const rel = (s.url || s.jsonUrl || "").replace(/&/g, "&");
|
||||
const linkPath = rel.startsWith("http")
|
||||
? rel
|
||||
: rel.startsWith("/")
|
||||
? rel
|
||||
: `${basePath}${rel}`;
|
||||
return {
|
||||
id: s.id || String(idx),
|
||||
code: s.id || String(idx),
|
||||
name: cleanName || s.id || `Group ${idx}`,
|
||||
linkPath,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Opel json-main-group.action and json-sub-group.action responses.
|
||||
* These use { vCfgData: [{ id, name, jsonUrl, gray, subheader }] } format.
|
||||
@@ -3611,7 +3779,22 @@ export class PL24FordLegacyService {
|
||||
const vehicle = this.parseP4VehicleResponse(html, vin, serviceName);
|
||||
if (!vehicle) return null;
|
||||
|
||||
const categories = this.parseP4NavigationCategories(html);
|
||||
// For P4 services that expose a VIN-based catalog (Ford P/T, Volvo legacy),
|
||||
// the real main groups live behind json-vin-main-group.action — the HTML
|
||||
// page only ships header crumbs. Prefer the JSON endpoint and fall back
|
||||
// to the legacy HTML scrape only when it returns nothing.
|
||||
let categories: PL24DecodedCategory[] = [];
|
||||
if (this.serviceSupportsVinMainGroups(serviceName)) {
|
||||
categories = await this.fetchVinMainGroups(vin, serviceName).catch((err) => {
|
||||
this.logger.warn(
|
||||
`P4 vin maingroups error for ${vin} (${serviceName}): ${(err as Error).message}`,
|
||||
);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
if (categories.length === 0) {
|
||||
categories = this.parseP4NavigationCategories(html);
|
||||
}
|
||||
|
||||
const result: PL24DecodedVehicle = {
|
||||
...vehicle,
|
||||
@@ -3626,6 +3809,74 @@ export class PL24FordLegacyService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Services whose vin-group page is backed by json-vin-*.action endpoints. */
|
||||
private serviceSupportsVinMainGroups(serviceName: string): boolean {
|
||||
return /^(fordp|fordt|volvo)_parts$/i.test(serviceName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the real top-level part groups for a VIN-decoded Ford/Volvo P4 vehicle.
|
||||
* PL24 ships this catalog level as JSON at json-vin-main-group.action —
|
||||
* the .action HTML page only contains header breadcrumbs.
|
||||
*/
|
||||
private async fetchVinMainGroups(
|
||||
vin: string,
|
||||
serviceName: string,
|
||||
): Promise<PL24DecodedCategory[]> {
|
||||
const config = getServiceConfig(serviceName);
|
||||
const basePath = config ? `${config.basePath}/${serviceName}` : `/ford/${serviceName}`;
|
||||
const url = `${basePath}/json-vin-main-group.action?vin=${encodeURIComponent(vin)}&lang=${this.language}`;
|
||||
|
||||
const raw = await this.fetchP4Page(url, serviceName, false, "tr");
|
||||
if (!raw) return [];
|
||||
|
||||
let data: {
|
||||
maingroups?: Array<{
|
||||
caption?: string;
|
||||
databaseKey?: string;
|
||||
identifier?: string;
|
||||
gray?: boolean;
|
||||
subheader?: boolean;
|
||||
jsonUrl?: string | null;
|
||||
url?: string | null;
|
||||
}>;
|
||||
};
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(data.maingroups)) return [];
|
||||
|
||||
const basePrefix = `${basePath}/`;
|
||||
return data.maingroups
|
||||
.filter((m) => !m.subheader && !m.gray && !!(m.jsonUrl || m.url) && !!m.caption)
|
||||
.map((m) => {
|
||||
const cleanName = (m.caption || "")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const code = m.databaseKey || m.identifier || cleanName.slice(0, 32);
|
||||
const rel = (m.jsonUrl || m.url || "").replace(/&/g, "&");
|
||||
const linkPath = rel.startsWith("http")
|
||||
? rel
|
||||
: rel.startsWith("/")
|
||||
? rel
|
||||
: `${basePrefix}${rel}`;
|
||||
return {
|
||||
code,
|
||||
nameEn: cleanName,
|
||||
nameTr: cleanName,
|
||||
description: null,
|
||||
iconUrl: null,
|
||||
subGroups: [],
|
||||
linkPath,
|
||||
linkWid: m.identifier ?? undefined,
|
||||
} satisfies PL24DecodedCategory;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse P4 legacy vehicle info from VIN group HTML.
|
||||
* Extracts from window.vehicles, data tables, or page content.
|
||||
|
||||
Reference in New Issue
Block a user