fix(pl24): Volvo + Ford + Opel/Vauxhall + Hyundai/Kia VIN decode (per-brand services) #100
@@ -25,6 +25,27 @@ import {
|
||||
getServiceConfig,
|
||||
} from "./pl24.types";
|
||||
|
||||
/**
|
||||
* Per-brand vehicle-info parser hook (shared-core + thin-brand-service pattern).
|
||||
* A thin PL24<Brand>Service supplies its own vehicle-info parser (e.g. Volvo reads the
|
||||
* `vinInfoTable` caption/value rows that the generic Ford-shaped parser misses); the
|
||||
* shared P4 engine below uses it, and its fields win over the generic extraction.
|
||||
*/
|
||||
export interface P4VehicleInfo {
|
||||
model?: string;
|
||||
year?: number;
|
||||
series?: string | null;
|
||||
bodyType?: string | null;
|
||||
engineCode?: string | null;
|
||||
engineType?: string | null;
|
||||
transmission?: string | null;
|
||||
colorCode?: string | null;
|
||||
productionDate?: string | null;
|
||||
}
|
||||
export interface P4BrandHooks {
|
||||
parseVehicleInfo?(html: string, vin: string): P4VehicleInfo | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PL24FordLegacyService {
|
||||
private readonly logger = new Logger(PL24FordLegacyService.name);
|
||||
@@ -58,6 +79,7 @@ export class PL24FordLegacyService {
|
||||
vin: string,
|
||||
serviceName: string,
|
||||
userId?: string,
|
||||
brandHooks?: P4BrandHooks,
|
||||
): Promise<PL24DecodedVehicle | null> {
|
||||
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}vehicle:${vin}`;
|
||||
const cached = await this.redis.getJson<PL24DecodedVehicle>(cacheKey);
|
||||
@@ -100,10 +122,10 @@ export class PL24FordLegacyService {
|
||||
this.logger.warn(`P4 legacy: still demo after retry for ${serviceName}`);
|
||||
return null;
|
||||
}
|
||||
return this.parseAndCacheVehicle(retryHtml, vin, serviceName, cacheKey);
|
||||
return this.parseAndCacheVehicle(retryHtml, vin, serviceName, cacheKey, brandHooks);
|
||||
}
|
||||
|
||||
return this.parseAndCacheVehicle(html, vin, serviceName, cacheKey);
|
||||
return this.parseAndCacheVehicle(html, vin, serviceName, cacheKey, brandHooks);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error(`P4 legacy VIN decode error (${serviceName}): ${err.message}`, err.stack);
|
||||
@@ -3862,8 +3884,9 @@ export class PL24FordLegacyService {
|
||||
vin: string,
|
||||
serviceName: string,
|
||||
cacheKey: string,
|
||||
brandHooks?: P4BrandHooks,
|
||||
): Promise<PL24DecodedVehicle | null> {
|
||||
const vehicle = this.parseP4VehicleResponse(html, vin, serviceName);
|
||||
const vehicle = this.parseP4VehicleResponse(html, vin, serviceName, brandHooks);
|
||||
if (!vehicle) return null;
|
||||
|
||||
// For P4 services that expose a VIN-based catalog (Ford P/T, Volvo legacy),
|
||||
@@ -3972,7 +3995,12 @@ export class PL24FordLegacyService {
|
||||
html: string,
|
||||
vin: string,
|
||||
serviceName = "fordt_parts",
|
||||
brandHooks?: P4BrandHooks,
|
||||
): Omit<PL24DecodedVehicle, "categories"> | null {
|
||||
// Brand-specific vehicle-info parser wins (e.g. Volvo's vinInfoTable, which the
|
||||
// generic Ford-shaped extraction below misses); generic only fills the gaps.
|
||||
const brandInfo = brandHooks?.parseVehicleInfo?.(html, vin) ?? null;
|
||||
|
||||
// Try extracting vehicle data from embedded JS
|
||||
const vehicles = this.extractScriptVariable<Array<Record<string, string>>>(html, "vehicles");
|
||||
const vehicleData = vehicles?.[0] || null;
|
||||
@@ -3980,19 +4008,19 @@ export class PL24FordLegacyService {
|
||||
// Try extracting from vehicle info table
|
||||
const tableRows = this.extractTableRows(html);
|
||||
|
||||
// Build vehicle info from whatever we found
|
||||
let model = "";
|
||||
let year = 0;
|
||||
let bodyType: string | null = null;
|
||||
let engineCode: string | null = null;
|
||||
let engineType: string | null = null;
|
||||
// Build vehicle info from whatever we found (brand values take precedence)
|
||||
let model = brandInfo?.model || "";
|
||||
let year = brandInfo?.year || 0;
|
||||
let bodyType: string | null = brandInfo?.bodyType ?? null;
|
||||
let engineCode: string | null = brandInfo?.engineCode ?? null;
|
||||
let engineType: string | null = brandInfo?.engineType ?? null;
|
||||
|
||||
if (vehicleData) {
|
||||
model = vehicleData.model || vehicleData.modelName || vehicleData.description || "";
|
||||
year = Number.parseInt(vehicleData.year || vehicleData.modelYear || "", 10) || 0;
|
||||
bodyType = vehicleData.bodyStyle || vehicleData.body || null;
|
||||
engineCode = vehicleData.engineCode || vehicleData.engine || null;
|
||||
engineType = vehicleData.engineDescription || vehicleData.engineType || null;
|
||||
model = model || vehicleData.model || vehicleData.modelName || vehicleData.description || "";
|
||||
year = year || Number.parseInt(vehicleData.year || vehicleData.modelYear || "", 10) || 0;
|
||||
bodyType = bodyType || vehicleData.bodyStyle || vehicleData.body || null;
|
||||
engineCode = engineCode || vehicleData.engineCode || vehicleData.engine || null;
|
||||
engineType = engineType || vehicleData.engineDescription || vehicleData.engineType || null;
|
||||
}
|
||||
|
||||
// Try to extract from page title or description
|
||||
@@ -4059,16 +4087,16 @@ export class PL24FordLegacyService {
|
||||
brand: SERVICE_TO_BRAND[serviceName] || serviceName.replace(/_parts$/, ""),
|
||||
model,
|
||||
year,
|
||||
series: null,
|
||||
series: brandInfo?.series ?? null,
|
||||
bodyType,
|
||||
engineCode,
|
||||
engineType,
|
||||
engineVolume: null,
|
||||
transmission: null,
|
||||
transmission: brandInfo?.transmission ?? null,
|
||||
driveType: null,
|
||||
colorCode: null,
|
||||
productionDate: null,
|
||||
raw: { html_length: html.length, has_vehicles_var: !!vehicleData },
|
||||
colorCode: brandInfo?.colorCode ?? null,
|
||||
productionDate: brandInfo?.productionDate ?? null,
|
||||
raw: { html_length: html.length, has_vehicles_var: !!vehicleData, fiResolved: !!brandInfo?.model },
|
||||
catalogInfo: {
|
||||
serviceName,
|
||||
vehicleId: vin,
|
||||
|
||||
44
apps/api/src/integrations/pl24/pl24-ford.service.spec.ts
Normal file
44
apps/api/src/integrations/pl24/pl24-ford.service.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PL24FordService } from "./pl24-ford.service";
|
||||
|
||||
const svc = new PL24FordService({} as never);
|
||||
const p = svc as unknown as {
|
||||
parseFordVinInfo(
|
||||
html: string,
|
||||
vin: string,
|
||||
): {
|
||||
model?: string;
|
||||
year?: number;
|
||||
transmission?: string | null;
|
||||
engineType?: string | null;
|
||||
bodyType?: string | null;
|
||||
productionDate?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
describe("PL24FordService.parseFordVinInfo", () => {
|
||||
// Real Ford vin-group info grid (the clean data the generic title-parser polluted with
|
||||
// "Ford Nutzfahrzeuge {VIN}: Transit Connect - TC7 …").
|
||||
const html = `<table><tbody>
|
||||
<tr><td class="caption">Sasi numarasi</td><td>NM0GXXTTPG6J66399</td></tr>
|
||||
<tr><td class="caption">Üretim tarihi</td><td>05.05.2006</td></tr>
|
||||
<tr><td class="caption">Araç Hattı</td><td>Transit Connect 2002-</td></tr>
|
||||
<tr><td class="caption">Motor Tipi</td><td>1.8L Duratorq TC (75PS) - Lynx</td></tr>
|
||||
<tr><td class="caption">Vites Kutusu</td><td>5 Vitesli Düz Vites Kutusu MTX75</td></tr>
|
||||
<tr><td class="caption">Gövde Tarzı</td><td>4 Kapı Sedan</td></tr>
|
||||
</tbody></table>`;
|
||||
|
||||
it("reads model/build-year/transmission/engine from the Ford info grid", () => {
|
||||
const r = p.parseFordVinInfo(html, "NM0GXXTTPG6J66399");
|
||||
expect(r?.model).toBe("Transit Connect 2002-");
|
||||
expect(r?.year).toBe(2006); // build date, NOT the VIN position-10 char (which gave 2016)
|
||||
expect(r?.transmission).toBe("5 Vitesli Düz Vites Kutusu MTX75");
|
||||
expect(r?.engineType).toBe("1.8L Duratorq TC (75PS) - Lynx");
|
||||
expect(r?.bodyType).toBe("4 Kapı Sedan");
|
||||
expect(r?.productionDate).toBe("05.05.2006");
|
||||
});
|
||||
|
||||
it("returns null when there is no Araç Hattı row (generic parser handles it)", () => {
|
||||
expect(p.parseFordVinInfo("<table><tr><td>x</td></tr></table>", "x")).toBeNull();
|
||||
});
|
||||
});
|
||||
76
apps/api/src/integrations/pl24/pl24-ford.service.ts
Normal file
76
apps/api/src/integrations/pl24/pl24-ford.service.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* PL24 Ford (fordp/fordt — passenger + commercial) — thin brand service over the shared P4 engine.
|
||||
*
|
||||
* Ford's vin-group page carries the real vehicle data in a caption/value grid, but the generic
|
||||
* parser grabbed the polluted page <title> ("Ford Nutzfahrzeuge {VIN}: Transit Connect - TC7…")
|
||||
* → messy model + VIN-char year (wrong) + empty transmission. This service reads the grid:
|
||||
* Araç Hattı → model ("Transit Connect 2002-"), Üretim tarihi → build year,
|
||||
* Vites Kutusu → transmission, Motor Tipi → engine, Gövde Tarzı → body, Sürüm/Seriler → series.
|
||||
* Categories/drill are unchanged (shared engine).
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import {
|
||||
type P4BrandHooks,
|
||||
type P4VehicleInfo,
|
||||
PL24FordLegacyService,
|
||||
} from "./pl24-ford-legacy.service";
|
||||
import type { PL24DecodedVehicle } from "./pl24.types";
|
||||
|
||||
@Injectable()
|
||||
export class PL24FordService {
|
||||
constructor(private readonly core: PL24FordLegacyService) {}
|
||||
|
||||
private readonly hooks: P4BrandHooks = {
|
||||
parseVehicleInfo: (html, vin) => this.parseFordVinInfo(html, vin),
|
||||
};
|
||||
|
||||
decodeVinForService(
|
||||
vin: string,
|
||||
serviceName: string,
|
||||
userId?: string,
|
||||
): Promise<PL24DecodedVehicle | null> {
|
||||
return this.core.decodeVinForService(vin, serviceName, userId, this.hooks);
|
||||
}
|
||||
|
||||
private parseFordVinInfo(html: string, _vin: string): P4VehicleInfo | null {
|
||||
const map: Record<string, string> = {};
|
||||
for (const m of html.matchAll(
|
||||
/<td[^>]*class="caption"[^>]*>([^<]*)<\/td>\s*<td[^>]*>([^<]*)<\/td>/g,
|
||||
)) {
|
||||
const k = m[1]
|
||||
.replace(/&[^;]+;/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.toLocaleLowerCase("tr");
|
||||
const v = m[2]
|
||||
.replace(/&[^;]+;/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (k && v && !(k in map)) map[k] = v;
|
||||
}
|
||||
const get = (...keys: string[]): string | null => {
|
||||
for (const k of keys) {
|
||||
const v = map[k.toLocaleLowerCase("tr")];
|
||||
if (v) return v;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const model = get("araç hattı", "arac hatti", "vehicle line");
|
||||
if (!model) return null; // no Ford info grid → let the generic parser try
|
||||
|
||||
const prod = get("üretim tarihi", "uretim tarihi", "build date");
|
||||
const year = prod
|
||||
? Number.parseInt(prod.match(/(19|20)\d{2}/)?.[0] || "", 10) || undefined
|
||||
: undefined;
|
||||
return {
|
||||
model,
|
||||
year,
|
||||
engineType: get("motor tipi", "engine"),
|
||||
transmission: get("vites kutusu", "transmission"),
|
||||
bodyType: get("gövde tarzı", "govde tarzi", "body style"),
|
||||
series: get("sürüm", "surum", "seriler") || null,
|
||||
productionDate: prod,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PL24HyundaiKiaService } from "./pl24-hyundai-kia.service";
|
||||
|
||||
const svc = new PL24HyundaiKiaService({} as never);
|
||||
const p = svc as unknown as {
|
||||
parseHyundaiKiaVinInfo(
|
||||
html: string,
|
||||
vin: string,
|
||||
): {
|
||||
model?: string;
|
||||
year?: number;
|
||||
transmission?: string | null;
|
||||
bodyType?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
describe("PL24HyundaiKiaService.parseHyundaiKiaVinInfo", () => {
|
||||
const html = `<title>Hyundai KMHBU51HP5U295422: GETZ 02: -OCT.2006 - partslink24</title>
|
||||
<table><tbody>
|
||||
<tr><td class="caption">Sasi numarasi</td><td>KMHBU51HP5U295422</td></tr>
|
||||
<tr><td class="caption">Üretim tarihi</td><td>14.Eyl.2004</td></tr>
|
||||
<tr><td class="caption">BODY TYPE</td><td>5 DR WAGON</td></tr>
|
||||
<tr><td class="caption">TRANSMISSION</td><td>5 SPEED MT 2WD</td></tr>
|
||||
<tr><td class="caption">ENGINE CAPACITY</td><td>1300 CC</td></tr>
|
||||
<tr><td class="caption">FUEL TYPE</td><td>MPI-SOHC</td></tr>
|
||||
</tbody></table>`;
|
||||
|
||||
it("reads model from title + build-year/transmission from the English grid", () => {
|
||||
const r = p.parseHyundaiKiaVinInfo(html, "KMHBU51HP5U295422");
|
||||
expect(r?.model).toBe("GETZ 02");
|
||||
expect(r?.year).toBe(2004);
|
||||
expect(r?.transmission).toBe("5 SPEED MT 2WD"); // would be missed by tr-locale lowercase
|
||||
expect(r?.bodyType).toBe("5 DR WAGON");
|
||||
});
|
||||
|
||||
it("strips ' - partslink24' when the title has no date-range colon (Kia)", () => {
|
||||
const r = p.parseHyundaiKiaVinInfo(
|
||||
"<title>Kia KNAD6814BL6353828: RIO / STONIC 17 - partslink24</title>",
|
||||
"KNAD6814BL6353828",
|
||||
);
|
||||
expect(r?.model).toBe("RIO / STONIC 17");
|
||||
});
|
||||
});
|
||||
82
apps/api/src/integrations/pl24/pl24-hyundai-kia.service.ts
Normal file
82
apps/api/src/integrations/pl24/pl24-hyundai-kia.service.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* PL24 Hyundai / Kia — thin brand service over the shared P4 engine.
|
||||
*
|
||||
* Friendly model lives in the <title> ("Hyundai {VIN}: GETZ 02: -OCT.2006 - partslink24"); the
|
||||
* generic parser kept the VIN-prefixed breadcrumb ("{VIN}: GET"). The info grid uses English
|
||||
* labels: Üretim tarihi (build date) / TRANSMISSION / BODY TYPE / ENGINE CAPACITY / FUEL TYPE.
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import {
|
||||
type P4BrandHooks,
|
||||
type P4VehicleInfo,
|
||||
PL24FordLegacyService,
|
||||
} from "./pl24-ford-legacy.service";
|
||||
import type { PL24DecodedVehicle } from "./pl24.types";
|
||||
|
||||
@Injectable()
|
||||
export class PL24HyundaiKiaService {
|
||||
constructor(private readonly core: PL24FordLegacyService) {}
|
||||
|
||||
private readonly hooks: P4BrandHooks = {
|
||||
parseVehicleInfo: (html, vin) => this.parseHyundaiKiaVinInfo(html, vin),
|
||||
};
|
||||
|
||||
decodeVinForService(
|
||||
vin: string,
|
||||
serviceName: string,
|
||||
userId?: string,
|
||||
): Promise<PL24DecodedVehicle | null> {
|
||||
return this.core.decodeVinForService(vin, serviceName, userId, this.hooks);
|
||||
}
|
||||
|
||||
private parseHyundaiKiaVinInfo(html: string, vin: string): P4VehicleInfo | null {
|
||||
// title: "Hyundai {VIN}: GETZ 02: -OCT.2006 - partslink24" → model = segment between the
|
||||
// VIN colon and the next colon ("GETZ 02").
|
||||
const title = (html.match(/<title>([^<]*)<\/title>/i)?.[1] || "")
|
||||
.replace(/&[^;]+;/g, " ")
|
||||
.replace(/\s*-\s*partslink24\s*$/i, "");
|
||||
let model: string | null = null;
|
||||
const afterVin = title.split(new RegExp(`${vin}\\s*:\\s*`, "i"))[1];
|
||||
if (afterVin) model = afterVin.split(":")[0].replace(/\s+/g, " ").trim() || null;
|
||||
if (!model) return null; // no model in title → let generic try
|
||||
|
||||
// Hyundai/Kia grid uses ENGLISH labels (TRANSMISSION/BODY TYPE/…); plain lowercase only —
|
||||
// Turkish-locale lowercase would map "I"→dotless "ı" and break the match.
|
||||
const map: Record<string, string> = {};
|
||||
for (const m of html.matchAll(
|
||||
/<td[^>]*class="caption"[^>]*>([^<]*)<\/td>\s*<td[^>]*>([^<]*)<\/td>/g,
|
||||
)) {
|
||||
const k = m[1]
|
||||
.replace(/&[^;]+;/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const v = m[2]
|
||||
.replace(/&[^;]+;/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (k && v && !(k in map)) map[k] = v;
|
||||
}
|
||||
const get = (...keys: string[]): string | null => {
|
||||
for (const k of keys) {
|
||||
const v = map[k.toLowerCase()];
|
||||
if (v) return v;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const prod = get("üretim tarihi", "uretim tarihi", "build date");
|
||||
const year = prod
|
||||
? Number.parseInt(prod.match(/(19|20)\d{2}/)?.[0] || "", 10) || undefined
|
||||
: undefined;
|
||||
const cap = get("engine capacity");
|
||||
const fuel = get("fuel type");
|
||||
return {
|
||||
model,
|
||||
year,
|
||||
engineType: [cap, fuel].filter(Boolean).join(" ") || null,
|
||||
transmission: get("transmission"),
|
||||
bodyType: get("body type"),
|
||||
productionDate: prod,
|
||||
};
|
||||
}
|
||||
}
|
||||
39
apps/api/src/integrations/pl24/pl24-opel.service.spec.ts
Normal file
39
apps/api/src/integrations/pl24/pl24-opel.service.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PL24OpelService } from "./pl24-opel.service";
|
||||
|
||||
const svc = new PL24OpelService({} as never);
|
||||
const p = svc as unknown as {
|
||||
parseOpelVinInfo(
|
||||
html: string,
|
||||
vin: string,
|
||||
): {
|
||||
model?: string;
|
||||
year?: number;
|
||||
transmission?: string | null;
|
||||
engineType?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
describe("PL24OpelService.parseOpelVinInfo", () => {
|
||||
const html = `<title>Opel W0LPD5EA6EG003869: P10 - ASTRA-J [2010-2020] - partslink24</title>
|
||||
<table><tbody>
|
||||
<tr><td class="caption">Sasi numarasi</td><td>W0LPD5EA6EG003869</td></tr>
|
||||
<tr><td class="caption">Model yili</td><td>2014 (A)</td></tr>
|
||||
<tr><td class="caption">Üretim tarihi</td><td>28.Ağu.2013</td></tr>
|
||||
<tr><td class="caption">Motor tipi</td><td>A13DTE</td></tr>
|
||||
<tr><td class="caption">Şanzıman kodu</td><td>MEM</td></tr>
|
||||
<tr><td class="caption">Karoseri</td><td>D69</td></tr>
|
||||
</tbody></table>`;
|
||||
|
||||
it("takes the friendly model from the title and year/transmission from the grid", () => {
|
||||
const r = p.parseOpelVinInfo(html, "W0LPD5EA6EG003869");
|
||||
expect(r?.model).toBe("ASTRA-J [2010-2020]");
|
||||
expect(r?.year).toBe(2014);
|
||||
expect(r?.transmission).toBe("MEM");
|
||||
expect(r?.engineType).toBe("A13DTE");
|
||||
});
|
||||
|
||||
it("returns null when the title has only a platform code (no ' - model')", () => {
|
||||
expect(p.parseOpelVinInfo("<title>Opel W0L: H00 - partslink24</title>", "x")).toBeNull();
|
||||
});
|
||||
});
|
||||
77
apps/api/src/integrations/pl24/pl24-opel.service.ts
Normal file
77
apps/api/src/integrations/pl24/pl24-opel.service.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* PL24 Opel / Vauxhall — thin brand service over the shared P4 engine.
|
||||
*
|
||||
* Opel's friendly model name lives in the <title> ("Opel {VIN}: {code} - ASTRA-J [2010-2020] -
|
||||
* partslink24"); the generic parser split on bare "-" and kept the platform code ("…: P10"). The
|
||||
* vin-group info grid carries Model yili / Üretim tarihi / Motor tipi / Şanzıman kodu / Karoseri.
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import {
|
||||
type P4BrandHooks,
|
||||
type P4VehicleInfo,
|
||||
PL24FordLegacyService,
|
||||
} from "./pl24-ford-legacy.service";
|
||||
import type { PL24DecodedVehicle } from "./pl24.types";
|
||||
|
||||
@Injectable()
|
||||
export class PL24OpelService {
|
||||
constructor(private readonly core: PL24FordLegacyService) {}
|
||||
|
||||
private readonly hooks: P4BrandHooks = {
|
||||
parseVehicleInfo: (html, vin) => this.parseOpelVinInfo(html, vin),
|
||||
};
|
||||
|
||||
decodeVinForService(
|
||||
vin: string,
|
||||
serviceName: string,
|
||||
userId?: string,
|
||||
): Promise<PL24DecodedVehicle | null> {
|
||||
return this.core.decodeVinForService(vin, serviceName, userId, this.hooks);
|
||||
}
|
||||
|
||||
private parseOpelVinInfo(html: string, _vin: string): P4VehicleInfo | null {
|
||||
// model = the title segment after the first " - " (the platform code precedes it)
|
||||
const title = (html.match(/<title>([^<]*)<\/title>/i)?.[1] || "")
|
||||
.replace(/&[^;]+;/g, " ")
|
||||
.replace(/\s*-\s*partslink24\s*$/i, "")
|
||||
.trim();
|
||||
const segs = title.split(/\s+-\s+/);
|
||||
const model = segs.length > 1 ? segs.slice(1).join(" - ").replace(/\s+/g, " ").trim() : null;
|
||||
if (!model) return null; // only a platform code / no model in title → let generic try
|
||||
|
||||
const map: Record<string, string> = {};
|
||||
for (const m of html.matchAll(
|
||||
/<td[^>]*class="caption"[^>]*>([^<]*)<\/td>\s*<td[^>]*>([^<]*)<\/td>/g,
|
||||
)) {
|
||||
const k = m[1]
|
||||
.replace(/&[^;]+;/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.toLocaleLowerCase("tr");
|
||||
const v = m[2]
|
||||
.replace(/&[^;]+;/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (k && v && !(k in map)) map[k] = v;
|
||||
}
|
||||
const get = (...keys: string[]): string | null => {
|
||||
for (const k of keys) {
|
||||
const v = map[k.toLocaleLowerCase("tr")];
|
||||
if (v) return v;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const yearRaw = get("model yılı", "model yili", "üretim tarihi", "uretim tarihi");
|
||||
const year = yearRaw
|
||||
? Number.parseInt(yearRaw.match(/(19|20)\d{2}/)?.[0] || "", 10) || undefined
|
||||
: undefined;
|
||||
return {
|
||||
model,
|
||||
year,
|
||||
engineType: get("motor tipi", "motor kodu"),
|
||||
transmission: get("şanzıman kodu", "sanzıman kodu", "şanzıman tipi"),
|
||||
bodyType: get("karoseri"),
|
||||
productionDate: get("üretim tarihi", "uretim tarihi"),
|
||||
};
|
||||
}
|
||||
}
|
||||
33
apps/api/src/integrations/pl24/pl24-volvo.service.spec.ts
Normal file
33
apps/api/src/integrations/pl24/pl24-volvo.service.spec.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PL24VolvoService } from "./pl24-volvo.service";
|
||||
|
||||
// parseVolvoVinInfo is the brand-specific fix; the shared engine is tested elsewhere.
|
||||
const svc = new PL24VolvoService({} as never);
|
||||
const p = svc as unknown as {
|
||||
parseVolvoVinInfo(
|
||||
html: string,
|
||||
vin: string,
|
||||
): { model?: string; year?: number; series?: string | null } | null;
|
||||
};
|
||||
|
||||
describe("PL24VolvoService.parseVolvoVinInfo", () => {
|
||||
// Real Volvo vin-group `vinInfoTable` shape (the model the Ford-shaped parser missed).
|
||||
const html = `<table id="vinInfoTable"><tbody>
|
||||
<tr><td class="caption">Sasi numarasi</td><td>YV1ZZA8VCM1066977</td></tr>
|
||||
<tr><td class="caption">Model yili</td><td>2021</td></tr>
|
||||
<tr><td class="caption">Model</td><td>V60 Cross Country (19-)</td></tr>
|
||||
<tr><td class="caption">Türü</td><td>V60 CC II</td></tr>
|
||||
<tr><td class="caption">Dis rengi</td><td>72700</td></tr>
|
||||
</tbody></table>`;
|
||||
|
||||
it("extracts model, year, and type from the vinInfoTable caption/value rows", () => {
|
||||
const r = p.parseVolvoVinInfo(html, "YV1ZZA8VCM1066977");
|
||||
expect(r?.model).toBe("V60 Cross Country (19-)");
|
||||
expect(r?.year).toBe(2021);
|
||||
expect(r?.series).toBe("V60 CC II");
|
||||
});
|
||||
|
||||
it("returns null when there is no Model row (so the generic parser still runs)", () => {
|
||||
expect(p.parseVolvoVinInfo("<table><tr><td>x</td></tr></table>", "x")).toBeNull();
|
||||
});
|
||||
});
|
||||
71
apps/api/src/integrations/pl24/pl24-volvo.service.ts
Normal file
71
apps/api/src/integrations/pl24/pl24-volvo.service.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* PL24 Volvo / Polestar — thin brand service over the shared P4 engine (PL24FordLegacyService).
|
||||
*
|
||||
* Volvo's vin-group page ships model/year/type in a `<table id="vinInfoTable">` caption/value
|
||||
* grid (e.g. Model = "V60 Cross Country (19-)", Model yili = 2021, Türü = "V60 CC II"). The
|
||||
* generic Ford-shaped parser only looks at window.vehicles / <title> / <h1> — none present for
|
||||
* Volvo (title is just "Volvo {VIN}") — so model fell back to "Volvo {VIN}". This service supplies
|
||||
* a brand vehicle-info parser; the shared engine handles fetch/session/categories/drill unchanged.
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import {
|
||||
type P4BrandHooks,
|
||||
type P4VehicleInfo,
|
||||
PL24FordLegacyService,
|
||||
} from "./pl24-ford-legacy.service";
|
||||
import type { PL24DecodedVehicle } from "./pl24.types";
|
||||
|
||||
@Injectable()
|
||||
export class PL24VolvoService {
|
||||
constructor(private readonly core: PL24FordLegacyService) {}
|
||||
|
||||
private readonly hooks: P4BrandHooks = {
|
||||
parseVehicleInfo: (html, vin) => this.parseVolvoVinInfo(html, vin),
|
||||
};
|
||||
|
||||
decodeVinForService(
|
||||
vin: string,
|
||||
serviceName: string,
|
||||
userId?: string,
|
||||
): Promise<PL24DecodedVehicle | null> {
|
||||
return this.core.decodeVinForService(vin, serviceName, userId, this.hooks);
|
||||
}
|
||||
|
||||
/** Parse the `vinInfoTable` caption/value rows that carry the real Volvo model/year/type. */
|
||||
private parseVolvoVinInfo(html: string, _vin: string): P4VehicleInfo | null {
|
||||
const map: Record<string, string> = {};
|
||||
for (const m of html.matchAll(
|
||||
/<td[^>]*class="caption"[^>]*>([^<]*)<\/td>\s*<td[^>]*>([^<]*)<\/td>/g,
|
||||
)) {
|
||||
const k = m[1]
|
||||
.replace(/&[^;]+;/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.toLocaleLowerCase("tr");
|
||||
const v = m[2]
|
||||
.replace(/&[^;]+;/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (k && v && !(k in map)) map[k] = v;
|
||||
}
|
||||
const get = (...keys: string[]): string | null => {
|
||||
for (const k of keys) {
|
||||
const v = map[k.toLocaleLowerCase("tr")];
|
||||
if (v) return v;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const model = get("model");
|
||||
if (!model) return null; // not a Volvo identification page → let the generic parser try
|
||||
|
||||
const yearRaw = get("model yılı", "model yili");
|
||||
const year = yearRaw ? Number.parseInt(yearRaw, 10) || undefined : undefined;
|
||||
return {
|
||||
model,
|
||||
year,
|
||||
series: get("türü", "turu"), // e.g. "V60 CC II"
|
||||
colorCode: get("dış rengi", "dis rengi"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,26 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PL24AuthService } from "./pl24-auth.service";
|
||||
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
|
||||
import { PL24FordService } from "./pl24-ford.service";
|
||||
import { PL24HyundaiKiaService } from "./pl24-hyundai-kia.service";
|
||||
import { PL24OpelService } from "./pl24-opel.service";
|
||||
import { PL24PsaService } from "./pl24-psa.service";
|
||||
import { PL24VolvoService } from "./pl24-volvo.service";
|
||||
import { PL24Service } from "./pl24.service";
|
||||
|
||||
const PL24_PROVIDERS = [
|
||||
PL24Service,
|
||||
PL24AuthService,
|
||||
PL24FordLegacyService,
|
||||
PL24PsaService,
|
||||
PL24VolvoService,
|
||||
PL24FordService,
|
||||
PL24OpelService,
|
||||
PL24HyundaiKiaService,
|
||||
];
|
||||
|
||||
@Module({
|
||||
providers: [PL24Service, PL24AuthService, PL24FordLegacyService, PL24PsaService],
|
||||
exports: [PL24Service, PL24AuthService, PL24FordLegacyService, PL24PsaService],
|
||||
providers: PL24_PROVIDERS,
|
||||
exports: PL24_PROVIDERS,
|
||||
})
|
||||
export class PL24Module {}
|
||||
|
||||
@@ -20,7 +20,11 @@ import { RedisService } from "../../redis/redis.service";
|
||||
import { StorageService } from "../../storage/storage.service";
|
||||
import { PL24AuthService } from "./pl24-auth.service";
|
||||
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
|
||||
import { PL24FordService } from "./pl24-ford.service";
|
||||
import { PL24HyundaiKiaService } from "./pl24-hyundai-kia.service";
|
||||
import { PL24OpelService } from "./pl24-opel.service";
|
||||
import { PL24PsaService } from "./pl24-psa.service";
|
||||
import { PL24VolvoService } from "./pl24-volvo.service";
|
||||
import { PL24_DEFAULTS } from "./pl24.constants";
|
||||
import {
|
||||
type PL24DecodedCategory,
|
||||
@@ -48,6 +52,10 @@ export class PL24Service {
|
||||
private readonly authService: PL24AuthService,
|
||||
private readonly fordLegacyService: PL24FordLegacyService,
|
||||
private readonly psaService: PL24PsaService,
|
||||
private readonly volvoService: PL24VolvoService,
|
||||
private readonly fordService: PL24FordService,
|
||||
private readonly opelService: PL24OpelService,
|
||||
private readonly hyundaiKiaService: PL24HyundaiKiaService,
|
||||
private configService: ConfigService,
|
||||
private redis: RedisService,
|
||||
private storage: StorageService,
|
||||
@@ -84,6 +92,22 @@ export class PL24Service {
|
||||
if (getServiceConfig(serviceName)?.architecture === "LEGACY_PSA") {
|
||||
return this.psaService.decodeVinForService(cleanVin, serviceName, userId);
|
||||
}
|
||||
// Volvo/Polestar: thin brand service supplies the vinInfoTable parser over the shared engine.
|
||||
if (getServiceConfig(serviceName)?.architecture === "LEGACY_VOLVO") {
|
||||
return this.volvoService.decodeVinForService(cleanVin, serviceName, userId);
|
||||
}
|
||||
// Ford (fordp/fordt): thin brand service reads the Ford info grid (model/year/transmission).
|
||||
if (getServiceConfig(serviceName)?.architecture === "LEGACY_FORD") {
|
||||
return this.fordService.decodeVinForService(cleanVin, serviceName, userId);
|
||||
}
|
||||
// Opel/Vauxhall: model from <title>, year/transmission/engine from the info grid.
|
||||
if (getServiceConfig(serviceName)?.architecture === "LEGACY_OPEL") {
|
||||
return this.opelService.decodeVinForService(cleanVin, serviceName, userId);
|
||||
}
|
||||
// Hyundai/Kia: model from <title>, build-year/transmission from the English-labelled grid.
|
||||
if (getServiceConfig(serviceName)?.architecture === "LEGACY_HYUNDAI_KIA") {
|
||||
return this.hyundaiKiaService.decodeVinForService(cleanVin, serviceName, userId);
|
||||
}
|
||||
if (isLegacyArchitecture(serviceName)) {
|
||||
return this.fordLegacyService.decodeVinForService(cleanVin, serviceName, userId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user