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:
2026-05-16 09:36:31 +03:00
parent 032217716e
commit be3fb2a4b5

View File

@@ -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(/&amp;/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(/&nbsp;/g, " ")
.replace(/&amp;/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(/&nbsp;/g, " ")
.replace(/\s+/g, " ")
.trim();
const rel = (s.url || s.jsonUrl || "").replace(/&amp;/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(/&nbsp;/g, " ")
.replace(/\s+/g, " ")
.trim();
const code = m.databaseKey || m.identifier || cleanName.slice(0, 32);
const rel = (m.jsonUrl || m.url || "").replace(/&amp;/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.