refactor(emex): rewrite EMEX integration, add category fallback

Replace browser-based Puppeteer scraper with standalone scraper wrapper.
Add EMEX as fallback source for categories when PL24 is unavailable.
Update vehicle decoding to use new synchronous EMEX API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sase Dev
2026-02-12 19:48:03 +00:00
parent 8b106cbb1f
commit a8eb8f4bdc
20 changed files with 1494 additions and 1209 deletions

View File

@@ -2,9 +2,10 @@ import { Module } from "@nestjs/common";
import { CategoriesController } from "./categories.controller";
import { CategoriesService } from "./categories.service";
import { PL24Module } from "../integrations/pl24/pl24.module";
import { EmexModule } from "../integrations/emex/emex.module";
@Module({
imports: [PL24Module],
imports: [PL24Module, EmexModule],
controllers: [CategoriesController],
providers: [CategoriesService],
exports: [CategoriesService],

View File

@@ -10,7 +10,15 @@ function createService(db: any) {
const pl24Service = {
getCategories: vi.fn().mockResolvedValue([]),
};
const service = new CategoriesService(db as any, redis as any, pl24Service as any);
const emexService = {
isSupported: vi.fn().mockReturnValue(false),
decodeVin: vi.fn().mockResolvedValue(null),
fetchCategoryParts: vi.fn().mockResolvedValue([]),
};
const storage = {
upload: vi.fn().mockResolvedValue("https://storage.sase.tr/sase-schemas/test.png"),
};
const service = new CategoriesService(db as any, redis as any, pl24Service as any, emexService as any, storage as any);
return { service, db, redis, pl24Service };
}

View File

@@ -4,6 +4,8 @@ import { DATABASE, Database } from "../database/database.provider";
import { categories, vehicles, schemaPics, parts } from "../database/schema/core";
import { RedisService } from "../redis/redis.service";
import { PL24Service } from "../integrations/pl24/pl24.service";
import { EmexService } from "../integrations/emex/emex.service";
import { StorageService } from "../storage/storage.service";
@Injectable()
export class CategoriesService {
@@ -13,6 +15,8 @@ export class CategoriesService {
@Inject(DATABASE) private db: Database,
private redis: RedisService,
private pl24Service: PL24Service,
private emexService: EmexService,
private storage: StorageService,
) {}
async getCategoryTree(vehicleId: string) {
@@ -41,28 +45,138 @@ export class CategoriesService {
const catalogInfo = rawData.catalogInfo;
if (catalogInfo?.serviceName && catalogInfo?.mainGroupsPath) {
const pl24Categories = await this.pl24Service.fetchMainGroups(
catalogInfo.serviceName,
catalogInfo.mainGroupsPath,
);
try {
const pl24Categories = await this.pl24Service.fetchMainGroups(
catalogInfo.serviceName,
catalogInfo.mainGroupsPath,
);
if (pl24Categories.length > 0) {
const insertData = pl24Categories.map((c) => ({
vehicleId,
name: c.nameTr || c.nameEn,
nameOriginal: c.nameEn,
parentId: null as string | null,
externalId: c.code,
linkPath: c.linkPath || null,
linkWid: c.linkWid || null,
source: "pl24" as const,
}));
if (pl24Categories.length > 0) {
// Deduplicate by name
const seenNames = new Set<string>();
const uniquePl24 = pl24Categories.filter((c) => {
const name = c.nameTr || c.nameEn;
if (seenNames.has(name)) return false;
seenNames.add(name);
return true;
});
dbCategories = await this.db.insert(categories).values(insertData).returning();
const insertData = uniquePl24.map((c) => ({
vehicleId,
name: c.nameTr || c.nameEn,
nameOriginal: c.nameEn,
parentId: null as string | null,
externalId: c.code,
linkPath: c.linkPath || null,
linkWid: c.linkWid || null,
source: "pl24" as const,
}));
dbCategories = await this.db.insert(categories).values(insertData).returning();
}
} catch (err) {
this.logger.warn(`PL24 category fetch failed for ${vehicleId}: ${(err as Error).message}`);
}
}
}
// If still no categories, try EMEX fallback
if (dbCategories.length === 0 && vehicle.vin && this.emexService.isSupported(vehicle.vin)) {
this.logger.log(`No PL24 categories for ${vehicle.vin}, trying EMEX fallback`);
try {
const emexResult = await this.emexService.decodeVin(vehicle.vin);
if (emexResult) {
const rawData = emexResult.raw as Record<string, unknown>;
const tree = rawData?.emexCategoryTree as Array<{
name: string;
gid: string | null;
url: string | null;
children: any[];
}> | undefined;
if (tree && tree.length > 0) {
// Recursive tree insertion from QuickGroups.aspx
this.logger.log(`Inserting ${tree.length} EMEX top-level category groups recursively`);
const insertNodes = async (
nodes: Array<{ name: string; gid: string | null; url: string | null; children?: any[] }>,
parentId: string | null,
) => {
for (const node of nodes) {
if (!node.name) continue;
const isLeaf = !node.children?.length;
const [inserted] = await this.db
.insert(categories)
.values({
vehicleId,
name: node.name,
nameOriginal: node.name,
parentId,
externalId: node.gid || null,
linkPath: isLeaf ? (node.url || null) : null,
linkWid: null as string | null,
source: "emex" as const,
})
.onConflictDoNothing()
.returning();
if (inserted) {
dbCategories.push(inserted);
if (node.children?.length) {
await insertNodes(node.children, inserted.id);
}
}
}
};
await insertNodes(tree, null);
this.logger.log(`Stored ${dbCategories.length} EMEX categories (tree) for ${vehicle.vin}`);
} else if (emexResult.categories.length > 0) {
// Flat fallback: insert all categories without hierarchy
const emexCats = (rawData?.emexCategories as Array<{ gid: string; name: string; url: string | null }>) || [];
const urlMap = new Map(emexCats.map((c) => [c.gid, c.url]));
const seenNames = new Set<string>();
const uniqueCategories = emexResult.categories.filter((c) => {
const name = c.nameTr || c.nameEn;
if (seenNames.has(name)) return false;
seenNames.add(name);
return true;
});
const insertData = uniqueCategories.map((c) => ({
vehicleId,
name: c.nameTr || c.nameEn,
nameOriginal: c.nameEn,
parentId: null as string | null,
externalId: c.code,
linkPath: urlMap.get(c.code) || null,
linkWid: null as string | null,
source: "emex" as const,
}));
dbCategories = await this.db.insert(categories).values(insertData).returning();
this.logger.log(`Stored ${dbCategories.length} EMEX categories (flat) for ${vehicle.vin}`);
}
// Update vehicle source/rawData if it was vin-api
if (vehicle.source === "vin-api") {
await this.db
.update(vehicles)
.set({
model: emexResult.model !== "Unknown" ? emexResult.model : vehicle.model,
rawData: emexResult.raw,
source: "emex",
})
.where(eq(vehicles.id, vehicleId));
}
}
} catch (emexErr) {
this.logger.warn(`EMEX category fallback failed for ${vehicle.vin}: ${(emexErr as Error).message}`);
}
}
// Build tree
const tree = this.buildTree(dbCategories);
await this.redis.setJson(cacheKey, tree, 3600);
@@ -118,7 +232,15 @@ export class CategoriesService {
);
if (subGroups.length > 0) {
const insertData = subGroups.map((sg) => ({
// Deduplicate by name
const seenNames = new Set<string>();
const uniqueSubGroups = subGroups.filter((sg) => {
if (seenNames.has(sg.name)) return false;
seenNames.add(sg.name);
return true;
});
const insertData = uniqueSubGroups.map((sg) => ({
vehicleId: category.vehicleId,
name: sg.name,
nameOriginal: sg.name,
@@ -181,7 +303,7 @@ export class CategoriesService {
.from(schemaPics)
.where(eq(schemaPics.categoryId, categoryId));
// If no parts in DB, fetch from PL24
// If no parts in DB, fetch from source
if (dbParts.length === 0 && category.linkPath) {
const [vehicle] = await this.db
.select()
@@ -189,7 +311,84 @@ export class CategoriesService {
.where(eq(vehicles.id, category.vehicleId))
.limit(1);
if (vehicle) {
if (vehicle && category.source === "emex") {
// EMEX: fetch parts + schema image via Puppeteer from QuickDetails URL
try {
const emexResult = await this.emexService.fetchCategoryParts(category.linkPath);
// Build position code → sequential integer mapping for hotspot linking
const posCodeToIndex = new Map<string, number>();
let nextIndex = 1;
for (const hs of emexResult.hotspots) {
if (!posCodeToIndex.has(hs.key)) {
posCodeToIndex.set(hs.key, nextIndex++);
}
}
if (emexResult.parts.length > 0) {
const insertData = emexResult.parts.map((p) => ({
vehicleId: vehicle.id,
categoryId,
oemCode: p.oemCode || "N/A",
name: p.nameEn || "Unknown",
nameOriginal: p.nameEn || null,
description: null as string | null,
quantity: null as number | null,
position: p.positionCode || null,
hotspotIndex: p.positionCode ? (posCodeToIndex.get(p.positionCode) ?? null) : null,
source: "emex" as const,
}));
dbParts = await this.db.insert(parts).values(insertData).returning();
this.logger.log(`Stored ${dbParts.length} EMEX parts for category ${categoryId}`);
}
// Download schema image from img.laximo.net and upload to MinIO
if (emexResult.schemaImageUrl && pics.length === 0) {
try {
const imgResp = await fetch(emexResult.schemaImageUrl, {
signal: AbortSignal.timeout(15000),
});
if (imgResp.ok) {
const buf = Buffer.from(await imgResp.arrayBuffer());
const ext = emexResult.schemaImageUrl.includes('.gif') ? 'gif' : 'png';
const key = `schemas/emex-${categoryId}.${ext}`;
const minioUrl = await this.storage.upload(key, buf, `image/${ext}`);
// Use scraper dimensions (from naturalWidth/Height), fallback to buffer parsing
const dims = (emexResult.schemaWidth && emexResult.schemaHeight)
? { width: emexResult.schemaWidth, height: emexResult.schemaHeight }
: this.getImageDimensions(buf, ext);
// Convert EMEX hotspots to storage format with sequential integer keys
const hotspotItems = emexResult.hotspots.map((hs) => ({
key: String(posCodeToIndex.get(hs.key) ?? hs.key),
label: hs.key,
areas: hs.areas,
}));
const [inserted] = await this.db
.insert(schemaPics)
.values({
categoryId,
imageUrl: minioUrl,
hotspots: JSON.stringify({ width: dims.width, height: dims.height, items: hotspotItems }),
source: "emex",
})
.returning();
pics.push(inserted);
this.logger.log(`Stored EMEX schema image in MinIO for category ${categoryId}: ${minioUrl} (${dims.width}x${dims.height})`);
}
} catch (imgErr) {
this.logger.warn(`Failed to download EMEX schema image: ${(imgErr as Error).message}`);
}
}
} catch (err) {
this.logger.error(`Failed to fetch EMEX parts for category ${categoryId}: ${(err as Error).message}`);
}
} else if (vehicle) {
// PL24: fetch parts via PL24 API
const rawData = vehicle.rawData as any;
const catalogInfo = rawData?.catalogInfo;
@@ -280,16 +479,16 @@ export class CategoriesService {
}
}
// Transform raw PL24 hotspots {key, areas} to frontend format
// Transform raw hotspots {key, label?, areas} to frontend format
const mappedHotspots = hotspots.flatMap(
(hs: { key: string; areas?: Array<{ left: number; top: number; width: number; height: number }> }) =>
(hs: { key: string; label?: string; areas?: Array<{ left: number; top: number; width: number; height: number }> }) =>
(hs.areas || []).map((area, areaIdx) => ({
id: `hs-${hs.key}-${areaIdx}`,
key: hs.key,
group: parseInt(hs.key, 10) || 0,
shape: "rect" as const,
coordinates: [area.left, area.top, area.width, area.height],
label: hs.key,
label: hs.label || hs.key,
})),
);
@@ -329,6 +528,22 @@ export class CategoriesService {
return { ...category, schemaPics: pics };
}
private getImageDimensions(buf: Buffer, ext: string): { width: number; height: number } {
try {
if (ext === 'gif' && buf.length >= 10) {
// GIF: width at bytes 6-7, height at bytes 8-9 (little-endian)
return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
}
if (ext === 'png' && buf.length >= 24) {
// PNG: width at bytes 16-19, height at bytes 20-23 (big-endian)
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
}
} catch {
// fallback
}
return { width: 0, height: 0 };
}
private buildTree(items: any[]): any[] {
const map = new Map<string, any>();
const roots: any[] = [];