fix(pl24): correct Volvo/Polestar VIN decode (model from vinInfoTable) via thin brand service
Volvo decoded as "Volvo {VIN}" with no real model: the generic Ford-shaped
parseP4VehicleResponse reads window.vehicles/<title>/<h1>, but Volvo ships model/year/type in a
<table id="vinInfoTable"> caption/value grid (Model="V60 Cross Country (19-)", Model yili=2021,
Türü="V60 CC II"). 9 vehicles affected (3 dev + 6 prod).
- Add a per-brand vehicle-info hook (P4BrandHooks.parseVehicleInfo) to the shared P4 engine
(PL24FordLegacyService); brand values win, generic fills gaps. Backward-compatible: no hook → identical.
- New thin PL24VolvoService supplies parseVolvoVinInfo (vinInfoTable parser); orchestrator routes
LEGACY_VOLVO decode to it. Categories/drill unchanged (shared engine).
Verified live vs 6 prod Volvo VINs: all decode real models (S80/S60/S40/V40/EX40·XC40/V60 CC) +
correct years; drill intact (motor → 7 subgroups). First step of the per-brand split
(shared core + thin brand services).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
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"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,17 @@ import { Module } from "@nestjs/common";
|
||||
import { PL24AuthService } from "./pl24-auth.service";
|
||||
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
|
||||
import { PL24PsaService } from "./pl24-psa.service";
|
||||
import { PL24VolvoService } from "./pl24-volvo.service";
|
||||
import { PL24Service } from "./pl24.service";
|
||||
|
||||
@Module({
|
||||
providers: [PL24Service, PL24AuthService, PL24FordLegacyService, PL24PsaService],
|
||||
exports: [PL24Service, PL24AuthService, PL24FordLegacyService, PL24PsaService],
|
||||
providers: [
|
||||
PL24Service,
|
||||
PL24AuthService,
|
||||
PL24FordLegacyService,
|
||||
PL24PsaService,
|
||||
PL24VolvoService,
|
||||
],
|
||||
exports: [PL24Service, PL24AuthService, PL24FordLegacyService, PL24PsaService, PL24VolvoService],
|
||||
})
|
||||
export class PL24Module {}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { StorageService } from "../../storage/storage.service";
|
||||
import { PL24AuthService } from "./pl24-auth.service";
|
||||
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
|
||||
import { PL24PsaService } from "./pl24-psa.service";
|
||||
import { PL24VolvoService } from "./pl24-volvo.service";
|
||||
import { PL24_DEFAULTS } from "./pl24.constants";
|
||||
import {
|
||||
type PL24DecodedCategory,
|
||||
@@ -48,6 +49,7 @@ export class PL24Service {
|
||||
private readonly authService: PL24AuthService,
|
||||
private readonly fordLegacyService: PL24FordLegacyService,
|
||||
private readonly psaService: PL24PsaService,
|
||||
private readonly volvoService: PL24VolvoService,
|
||||
private configService: ConfigService,
|
||||
private redis: RedisService,
|
||||
private storage: StorageService,
|
||||
@@ -84,6 +86,10 @@ 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);
|
||||
}
|
||||
if (isLegacyArchitecture(serviceName)) {
|
||||
return this.fordLegacyService.decodeVinForService(cleanVin, serviceName, userId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user