feat: Ford legacy support, EMEX browser pooling, collapsible sidebar
- Add PL24 Ford legacy service for fordt_parts architecture - Refactor EMEX to use persistent browser pool instead of per-call instances - Make vehicle decode resilient: fallback to PL24 when Corgi doesn't recognize VIN - Add collapsible sidebar with persistent user preference - Improve brand access guard and categories service - Add debug/test scripts for VIN e2e testing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -49,7 +49,7 @@
|
||||
"@types/node": "^22.0.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"puppeteer": "^23.0.0",
|
||||
"playwright": "^1.50.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
|
||||
@@ -81,7 +81,7 @@ export class CategoriesService {
|
||||
}
|
||||
|
||||
// If still no categories, try EMEX fallback
|
||||
if (dbCategories.length === 0 && vehicle.vin && this.emexService.isSupported(vehicle.vin)) {
|
||||
if (dbCategories.length === 0 && vehicle.vin) {
|
||||
this.logger.log(`No PL24 categories for ${vehicle.vin}, trying EMEX fallback`);
|
||||
try {
|
||||
const emexResult = await this.emexService.decodeVin(vehicle.vin);
|
||||
@@ -105,6 +105,8 @@ export class CategoriesService {
|
||||
for (const node of nodes) {
|
||||
if (!node.name) continue;
|
||||
const isLeaf = !node.children?.length;
|
||||
// URL'si olmayan leaf node'lar dead-end — kaydetme
|
||||
if (isLeaf && !node.url) continue;
|
||||
|
||||
const [inserted] = await this.db
|
||||
.insert(categories)
|
||||
@@ -221,8 +223,8 @@ export class CategoriesService {
|
||||
return [];
|
||||
}
|
||||
|
||||
// BOM links are leaf categories — they return parts, not subgroups
|
||||
if (linkPath.includes("/bom/")) {
|
||||
// BOM / servicepart item links are leaf categories — they return parts, not subgroups
|
||||
if (linkPath.includes("/bom/") || linkPath.includes("/bomdetails") || linkPath.includes("/partinfo/") || linkPath.includes("/servicepart/vin_items")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -315,8 +317,15 @@ export class CategoriesService {
|
||||
.from(schemaPics)
|
||||
.where(eq(schemaPics.categoryId, categoryId));
|
||||
|
||||
// If no parts in DB, fetch from source
|
||||
if (dbParts.length === 0 && category.linkPath) {
|
||||
// Fetch parts and/or schema image from source if missing
|
||||
const needParts = dbParts.length === 0;
|
||||
const needImage = pics.length === 0;
|
||||
|
||||
if ((needParts || needImage) && !category.linkPath && category.source === "emex") {
|
||||
this.logger.warn(`EMEX leaf category ${categoryId} has no linkPath — dead-end node`);
|
||||
}
|
||||
|
||||
if ((needParts || needImage) && category.linkPath) {
|
||||
const [vehicle] = await this.db
|
||||
.select()
|
||||
.from(vehicles)
|
||||
@@ -337,7 +346,7 @@ export class CategoriesService {
|
||||
}
|
||||
}
|
||||
|
||||
if (emexResult.parts.length > 0) {
|
||||
if (needParts && emexResult.parts.length > 0) {
|
||||
const insertData = emexResult.parts.map((p) => ({
|
||||
vehicleId: vehicle.id,
|
||||
categoryId,
|
||||
@@ -356,7 +365,7 @@ export class CategoriesService {
|
||||
}
|
||||
|
||||
// Download schema image from img.laximo.net and upload to MinIO
|
||||
if (emexResult.schemaImageUrl && pics.length === 0) {
|
||||
if (needImage && emexResult.schemaImageUrl) {
|
||||
try {
|
||||
const imgResp = await fetch(emexResult.schemaImageUrl, {
|
||||
signal: AbortSignal.timeout(15000),
|
||||
@@ -400,7 +409,7 @@ export class CategoriesService {
|
||||
this.logger.error(`Failed to fetch EMEX parts for category ${categoryId}: ${(err as Error).message}`);
|
||||
}
|
||||
} else if (vehicle) {
|
||||
// PL24: fetch parts via PL24 API
|
||||
// PL24: fetch parts + schema image via PL24 API
|
||||
const rawData = vehicle.rawData as any;
|
||||
const catalogInfo = rawData?.catalogInfo;
|
||||
|
||||
@@ -412,7 +421,7 @@ export class CategoriesService {
|
||||
);
|
||||
|
||||
// Store parts
|
||||
if (pl24Result.parts.length > 0) {
|
||||
if (needParts && pl24Result.parts.length > 0) {
|
||||
const insertData = pl24Result.parts.map((p) => ({
|
||||
vehicleId: vehicle.id,
|
||||
categoryId,
|
||||
@@ -422,7 +431,10 @@ export class CategoriesService {
|
||||
description: p.description || null,
|
||||
quantity: p.quantity || null,
|
||||
position: p.positionCode || null,
|
||||
hotspotIndex: p.hotspotId ? parseInt(p.hotspotId, 10) || null : null,
|
||||
hotspotIndex: p.hotspotId ? (() => {
|
||||
const val = parseInt(p.hotspotId!, 10);
|
||||
return (val > 0 && val <= 2147483647) ? val : null;
|
||||
})() : null,
|
||||
source: "pl24" as const,
|
||||
}));
|
||||
|
||||
@@ -430,7 +442,7 @@ export class CategoriesService {
|
||||
}
|
||||
|
||||
// Store schema image if available
|
||||
if (pl24Result.schemaImageUrl && pics.length === 0) {
|
||||
if (needImage && pl24Result.schemaImageUrl) {
|
||||
const imageResult = await this.pl24Service.getSchemaImage(
|
||||
pl24Result.schemaImageUrl,
|
||||
catalogInfo.serviceName,
|
||||
@@ -566,8 +578,11 @@ export class CategoriesService {
|
||||
|
||||
return cats.map((c) => {
|
||||
const dbChildCount = childCountMap.get(c.id) || 0;
|
||||
// Leaf if: has BOM linkPath, OR has no linkPath and no DB children
|
||||
const isLeaf = c.linkPath?.includes("/bom/") || (!c.linkPath && dbChildCount === 0);
|
||||
// EMEX: leaf only if linkPath exists and no DB children
|
||||
// PL24: leaf if BOM/servicepart-items linkPath, or no linkPath and no DB children
|
||||
const isLeaf = c.source === "emex"
|
||||
? (!!c.linkPath && dbChildCount === 0)
|
||||
: (c.linkPath?.includes("/bom/") || c.linkPath?.includes("/bomdetails") || c.linkPath?.includes("/partinfo/") || c.linkPath?.includes("/servicepart/vin_items") || (!c.linkPath && dbChildCount === 0));
|
||||
return {
|
||||
...c,
|
||||
schemaImageUrl: picMap.get(c.id) || null,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Inject, Injectable } from "@nestjs/common";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { DATABASE, Database } from "../../database/database.provider";
|
||||
import { userSubscriptions, userBrands } from "../../database/schema/core";
|
||||
import { userSubscriptions, userBrands, plans } from "../../database/schema/core";
|
||||
|
||||
@Injectable()
|
||||
export class BrandAccessGuard implements CanActivate {
|
||||
@@ -22,8 +22,12 @@ export class BrandAccessGuard implements CanActivate {
|
||||
|
||||
// Check if user has an active subscription with access to this brand
|
||||
const activeSub = await this.db
|
||||
.select()
|
||||
.select({
|
||||
id: userSubscriptions.id,
|
||||
brandCount: plans.brandCount,
|
||||
})
|
||||
.from(userSubscriptions)
|
||||
.innerJoin(plans, eq(userSubscriptions.planId, plans.id))
|
||||
.where(and(eq(userSubscriptions.userId, user.id), eq(userSubscriptions.status, "active")))
|
||||
.limit(1);
|
||||
|
||||
@@ -31,10 +35,12 @@ export class BrandAccessGuard implements CanActivate {
|
||||
throw new ForbiddenException("No active subscription");
|
||||
}
|
||||
|
||||
// Check if the subscription includes this brand (brandCount=0 means all brands)
|
||||
const subscription = activeSub[0];
|
||||
|
||||
// Check userBrands junction
|
||||
// brandCount === 0 means unlimited (Full Paket) — all brands accessible
|
||||
if (subscription.brandCount === 0) return true;
|
||||
|
||||
// Check userBrands junction for limited plans
|
||||
const brandAccess = await this.db
|
||||
.select()
|
||||
.from(userBrands)
|
||||
|
||||
@@ -29,6 +29,8 @@ const BRANDS_DATA = [
|
||||
{ name: "Mini", slug: "mini" },
|
||||
{ name: "Dacia", slug: "dacia" },
|
||||
{ name: "Subaru", slug: "subaru" },
|
||||
{ name: "Suzuki", slug: "suzuki" },
|
||||
{ name: "Mitsubishi", slug: "mitsubishi" },
|
||||
];
|
||||
|
||||
const PLANS_DATA = [
|
||||
|
||||
@@ -34,7 +34,7 @@ const WMI_DATABASE: Record<string, string> = {
|
||||
// Kia
|
||||
KNA: "Kia", KND: "Kia",
|
||||
// Ford
|
||||
WF0: "Ford", "1FA": "Ford", "3FA": "Ford",
|
||||
WF0: "Ford", NM0: "Ford", "1FA": "Ford", "3FA": "Ford",
|
||||
// Opel
|
||||
W0L: "Opel",
|
||||
// Skoda
|
||||
@@ -46,7 +46,7 @@ const WMI_DATABASE: Record<string, string> = {
|
||||
// Nissan
|
||||
JN1: "Nissan", "1N4": "Nissan", "3N1": "Nissan",
|
||||
// Mazda
|
||||
JM1: "Mazda", JM3: "Mazda",
|
||||
JMZ: "Mazda", JM1: "Mazda", JM3: "Mazda",
|
||||
// Porsche
|
||||
WP0: "Porsche", WP1: "Porsche",
|
||||
// Land Rover
|
||||
@@ -59,6 +59,10 @@ const WMI_DATABASE: Record<string, string> = {
|
||||
UU1: "Dacia",
|
||||
// Subaru
|
||||
JF1: "Subaru", JF2: "Subaru",
|
||||
// Suzuki
|
||||
JS2: "Suzuki", JS3: "Suzuki", TSM: "Suzuki", MA3: "Suzuki", MBH: "Suzuki",
|
||||
// Mitsubishi
|
||||
JMB: "Mitsubishi", JMY: "Mitsubishi", MMB: "Mitsubishi", ML3: "Mitsubishi",
|
||||
};
|
||||
|
||||
const YEAR_MAP: Record<string, number> = {
|
||||
|
||||
286
apps/api/src/integrations/emex/emex.browser.ts
Normal file
286
apps/api/src/integrations/emex/emex.browser.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* EMEX Browser Service — Singleton Playwright browser manager
|
||||
*
|
||||
* Keeps a single Chromium instance alive for the app lifetime.
|
||||
* Each scraping request gets its own Page (tab) via acquirePage().
|
||||
* Session cookies are shared through a single BrowserContext.
|
||||
*
|
||||
* Features:
|
||||
* - Semaphore limits concurrent pages (default 3)
|
||||
* - Session cookie auto-refresh (25 min TTL)
|
||||
* - Crash recovery (auto-relaunch if browser disconnects)
|
||||
*/
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { Browser, BrowserContext, Page } from 'playwright';
|
||||
|
||||
const SESSION_TTL_MS = 25 * 60 * 1000; // 25 minutes
|
||||
const MAX_CONCURRENT_PAGES = 3;
|
||||
const EMEX_BASE_URL = 'https://emexdwc.ae';
|
||||
|
||||
/** Simple counting semaphore */
|
||||
class Semaphore {
|
||||
private current = 0;
|
||||
private queue: Array<() => void> = [];
|
||||
|
||||
constructor(private readonly max: number) {}
|
||||
|
||||
acquire(): Promise<void> {
|
||||
if (this.current < this.max) {
|
||||
this.current++;
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
this.queue.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
release(): void {
|
||||
const next = this.queue.shift();
|
||||
if (next) {
|
||||
next(); // hand slot to next waiter
|
||||
} else {
|
||||
this.current--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface AcquiredPage {
|
||||
page: Page;
|
||||
release: () => Promise<void>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(EmexBrowserService.name);
|
||||
private browser: Browser | null = null;
|
||||
private context: BrowserContext | null = null;
|
||||
private sessionExpiry = 0;
|
||||
private readonly semaphore: Semaphore;
|
||||
private launching: Promise<void> | null = null;
|
||||
|
||||
private readonly useProxy: boolean;
|
||||
private readonly proxyHost: string;
|
||||
private readonly proxyPortStart: number;
|
||||
private readonly proxyPortEnd: number;
|
||||
private readonly proxyUsername: string;
|
||||
private readonly proxyPassword: string;
|
||||
private startedAt = 0;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.semaphore = new Semaphore(MAX_CONCURRENT_PAGES);
|
||||
|
||||
this.useProxy =
|
||||
this.configService.get<string>('EMEX_USE_PROXY', 'false') === 'true';
|
||||
this.proxyHost = this.configService.get<string>(
|
||||
'EMEX_PROXY_HOST',
|
||||
'74.81.81.81',
|
||||
);
|
||||
this.proxyPortStart = this.configService.get<number>(
|
||||
'EMEX_PROXY_PORT_START',
|
||||
10000,
|
||||
);
|
||||
this.proxyPortEnd = this.configService.get<number>(
|
||||
'EMEX_PROXY_PORT_END',
|
||||
10099,
|
||||
);
|
||||
this.proxyUsername = this.configService.get<string>(
|
||||
'EMEX_PROXY_USER',
|
||||
'1726bbe361918676d44e',
|
||||
);
|
||||
this.proxyPassword = this.configService.get<string>(
|
||||
'EMEX_PROXY_PASS',
|
||||
'f11c7b6128cc86c6',
|
||||
);
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
await this.launchBrowser();
|
||||
this.logger.log('Browser launched on module init');
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
this.logger.error(
|
||||
`Failed to launch browser on init: ${e.message}`,
|
||||
e.stack,
|
||||
);
|
||||
// Non-fatal — will retry on first acquirePage()
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.closeBrowser();
|
||||
this.logger.log('Browser closed on module destroy');
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a new Page (tab) from the shared browser.
|
||||
* Caller MUST call release() when done.
|
||||
*/
|
||||
async acquirePage(): Promise<AcquiredPage> {
|
||||
await this.semaphore.acquire();
|
||||
|
||||
try {
|
||||
await this.ensureBrowser();
|
||||
await this.ensureSession();
|
||||
|
||||
const page = await this.context!.newPage();
|
||||
|
||||
let released = false;
|
||||
const release = async () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
try {
|
||||
if (!page.isClosed()) {
|
||||
await page.close();
|
||||
}
|
||||
} catch {
|
||||
// page may already be closed
|
||||
}
|
||||
this.semaphore.release();
|
||||
};
|
||||
|
||||
return { page, release };
|
||||
} catch (err) {
|
||||
this.semaphore.release();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check for monitoring
|
||||
*/
|
||||
healthCheck(): {
|
||||
browserConnected: boolean;
|
||||
sessionValid: boolean;
|
||||
uptimeMs: number;
|
||||
} {
|
||||
return {
|
||||
browserConnected: this.browser?.isConnected() ?? false,
|
||||
sessionValid: Date.now() < this.sessionExpiry,
|
||||
uptimeMs: this.startedAt ? Date.now() - this.startedAt : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Private ─────────────────────────────────────────────
|
||||
|
||||
private async launchBrowser(): Promise<void> {
|
||||
// Prevent duplicate launches
|
||||
if (this.launching) {
|
||||
return this.launching;
|
||||
}
|
||||
|
||||
this.launching = this._doLaunch();
|
||||
try {
|
||||
await this.launching;
|
||||
} finally {
|
||||
this.launching = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async _doLaunch(): Promise<void> {
|
||||
// Dynamic import — playwright is a devDependency
|
||||
const { chromium } = await import('playwright');
|
||||
|
||||
const launchOptions: Record<string, unknown> = {
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-accelerated-2d-canvas',
|
||||
'--disable-gpu',
|
||||
],
|
||||
};
|
||||
|
||||
if (this.useProxy) {
|
||||
const port = this.randomProxyPort();
|
||||
launchOptions.proxy = {
|
||||
server: `http://${this.proxyHost}:${port}`,
|
||||
username: this.proxyUsername,
|
||||
password: this.proxyPassword,
|
||||
};
|
||||
this.logger.log(`Using proxy: ${this.proxyHost}:${port}`);
|
||||
}
|
||||
|
||||
this.browser = await chromium.launch(launchOptions);
|
||||
this.context = await this.browser.newContext({
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
});
|
||||
|
||||
this.startedAt = Date.now();
|
||||
this.sessionExpiry = 0; // force session establish on first acquirePage
|
||||
|
||||
// Auto-recover on disconnect
|
||||
this.browser.on('disconnected', () => {
|
||||
this.logger.warn('Browser disconnected — will relaunch on next request');
|
||||
this.browser = null;
|
||||
this.context = null;
|
||||
this.sessionExpiry = 0;
|
||||
});
|
||||
}
|
||||
|
||||
private async closeBrowser(): Promise<void> {
|
||||
if (this.browser) {
|
||||
try {
|
||||
await this.browser.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
this.browser = null;
|
||||
this.context = null;
|
||||
this.sessionExpiry = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureBrowser(): Promise<void> {
|
||||
if (this.browser?.isConnected()) return;
|
||||
this.logger.log('Browser not connected — relaunching');
|
||||
await this.launchBrowser();
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit baseUrl to establish/refresh ASP.NET session cookie
|
||||
*/
|
||||
private async ensureSession(): Promise<void> {
|
||||
if (Date.now() < this.sessionExpiry) return;
|
||||
|
||||
this.logger.log('Establishing EMEX session...');
|
||||
const page = await this.context!.newPage();
|
||||
try {
|
||||
await page.goto(EMEX_BASE_URL, {
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
const cookies = await this.context!.cookies();
|
||||
const session = cookies.find((c) => c.name === 'ASP.NET_SessionId');
|
||||
if (session) {
|
||||
this.sessionExpiry = Date.now() + SESSION_TTL_MS;
|
||||
this.logger.log('Session established, TTL 25 min');
|
||||
} else {
|
||||
this.logger.warn('No session cookie found after visiting baseUrl');
|
||||
// Still set a short TTL to avoid hammering
|
||||
this.sessionExpiry = Date.now() + 60_000;
|
||||
}
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
private randomProxyPort(): number {
|
||||
return (
|
||||
Math.floor(
|
||||
Math.random() * (this.proxyPortEnd - this.proxyPortStart + 1),
|
||||
) + this.proxyPortStart
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { EmexBrowserService } from "./emex.browser";
|
||||
import { EmexService } from "./emex.service";
|
||||
|
||||
@Module({
|
||||
providers: [EmexService],
|
||||
providers: [EmexBrowserService, EmexService],
|
||||
exports: [EmexService],
|
||||
})
|
||||
export class EmexModule {}
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
* NestJS service for emexdwc.ae VIN integration.
|
||||
* Wraps the EmexVinScraper from scripts/emex-vin-scraper.js
|
||||
* and provides standardized DecodedVehicle responses.
|
||||
*
|
||||
* Uses EmexBrowserService for a persistent singleton browser —
|
||||
* each request gets a pre-created Page (tab) via acquirePage().
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -12,7 +15,6 @@ import {
|
||||
BadRequestException,
|
||||
ServiceUnavailableException,
|
||||
InternalServerErrorException,
|
||||
OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as path from 'path';
|
||||
@@ -25,10 +27,11 @@ import {
|
||||
CATALOG_MAP,
|
||||
} from './emex.types';
|
||||
import { mapEmexResponse, createEmptyDecodedVehicle } from './emex.mapper';
|
||||
import { EmexBrowserService } from './emex.browser';
|
||||
|
||||
// Type definition for the imported scraper module
|
||||
interface EmexScraperModule {
|
||||
EmexVinScraper: new () => EmexVinScraperInstance;
|
||||
EmexVinScraper: new (options?: { page?: unknown }) => EmexVinScraperInstance;
|
||||
getCatalogCode: (vin: string) => string | null;
|
||||
getYearFromVIN: (vin: string) => number | null;
|
||||
CONFIG: Record<string, unknown>;
|
||||
@@ -50,7 +53,7 @@ interface EmexVinScraperInstance {
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EmexService implements OnModuleDestroy {
|
||||
export class EmexService {
|
||||
private readonly logger = new Logger(EmexService.name);
|
||||
private scraperModule: EmexScraperModule | null = null;
|
||||
private isInitialized = false;
|
||||
@@ -60,7 +63,10 @@ export class EmexService implements OnModuleDestroy {
|
||||
private readonly timeout: number;
|
||||
private readonly debug: boolean;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private browserService: EmexBrowserService,
|
||||
) {
|
||||
// __dirname is apps/api/src/integrations/emex/ or dist/integrations/emex/
|
||||
// Scraper lives at <monorepo-root>/scripts/emex-vin-scraper.js
|
||||
const monorepoRoot = path.resolve(__dirname, '..', '..', '..', '..', '..');
|
||||
@@ -76,10 +82,6 @@ export class EmexService implements OnModuleDestroy {
|
||||
this.logger.log(`EMEX Service initialized with scraper path: ${this.scraperPath}`);
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
// Nothing to clean up — each scraper instance is created and closed per-call
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily initialize the scraper module
|
||||
*/
|
||||
@@ -127,18 +129,24 @@ export class EmexService implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new scraper instance and initializes browser
|
||||
* Creates a scraper instance bound to a pre-created page from the browser pool.
|
||||
* Returns { scraper, release } — caller MUST call release() in finally.
|
||||
*/
|
||||
private async createScraperInstance(): Promise<EmexVinScraperInstance> {
|
||||
private async createScraperInstance(): Promise<{
|
||||
scraper: EmexVinScraperInstance;
|
||||
release: () => Promise<void>;
|
||||
}> {
|
||||
await this.initializeScraper();
|
||||
|
||||
if (!this.scraperModule) {
|
||||
throw new InternalServerErrorException('EMEX scraper modulu yuklenemedi');
|
||||
}
|
||||
|
||||
const instance = new this.scraperModule.EmexVinScraper();
|
||||
await instance.init();
|
||||
return instance;
|
||||
const { page, release } = await this.browserService.acquirePage();
|
||||
const scraper = new this.scraperModule.EmexVinScraper({ page });
|
||||
// init() is a no-op in managed mode, but call it for consistency
|
||||
await scraper.init();
|
||||
return { scraper, release };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,19 +173,24 @@ export class EmexService implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a VIN number using EMEX scraper
|
||||
* Decodes a VIN number using EMEX scraper.
|
||||
* Works with or without a known catalog code — the scraper
|
||||
* falls back to VIN URL search which doesn't require one.
|
||||
*/
|
||||
async decodeVin(vin: string): Promise<DecodedVehicle> {
|
||||
const cleanVin = vin.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, '');
|
||||
|
||||
this.validateVin(cleanVin);
|
||||
|
||||
this.logger.log(`Decoding VIN: ${cleanVin}`);
|
||||
const supported = this.isSupported(cleanVin);
|
||||
this.logger.log(`Decoding VIN: ${cleanVin} (catalog supported: ${supported})`);
|
||||
|
||||
let scraper: EmexVinScraperInstance | null = null;
|
||||
let release: (() => Promise<void>) | null = null;
|
||||
|
||||
try {
|
||||
scraper = await this.createScraperInstance();
|
||||
const instance = await this.createScraperInstance();
|
||||
const scraper = instance.scraper;
|
||||
release = instance.release;
|
||||
|
||||
const response = await this.executeWithTimeout(
|
||||
scraper.searchByVIN(cleanVin),
|
||||
@@ -270,7 +283,7 @@ export class EmexService implements OnModuleDestroy {
|
||||
|
||||
if (
|
||||
err.message?.includes('browser') ||
|
||||
err.message?.includes('puppeteer') ||
|
||||
err.message?.includes('playwright') ||
|
||||
err.message?.includes('navigation')
|
||||
) {
|
||||
this.logger.error(`Browser error: ${err.message}`, err.stack);
|
||||
@@ -284,12 +297,12 @@ export class EmexService implements OnModuleDestroy {
|
||||
'VIN sorgulama sirasinda bir hata olustu',
|
||||
);
|
||||
} finally {
|
||||
if (scraper) {
|
||||
if (release) {
|
||||
try {
|
||||
await scraper.close();
|
||||
await release();
|
||||
} catch (closeError) {
|
||||
const err = closeError as Error;
|
||||
this.logger.warn(`Error closing scraper: ${err.message}`);
|
||||
this.logger.warn(`Error releasing page: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -363,10 +376,12 @@ export class EmexService implements OnModuleDestroy {
|
||||
|
||||
this.logger.log(`Fetching parts from category URL: ${categoryUrl}`);
|
||||
|
||||
let scraper: EmexVinScraperInstance | null = null;
|
||||
let release: (() => Promise<void>) | null = null;
|
||||
|
||||
try {
|
||||
scraper = await this.createScraperInstance();
|
||||
const instance = await this.createScraperInstance();
|
||||
const scraper = instance.scraper;
|
||||
release = instance.release;
|
||||
|
||||
const result = await this.executeWithTimeout(
|
||||
scraper.getParts(categoryUrl),
|
||||
@@ -387,12 +402,12 @@ export class EmexService implements OnModuleDestroy {
|
||||
this.logger.error(`Failed to fetch category parts: ${err.message}`);
|
||||
return { parts: [], schemaImageUrl: null, hotspots: [], schemaWidth: 0, schemaHeight: 0 };
|
||||
} finally {
|
||||
if (scraper) {
|
||||
if (release) {
|
||||
try {
|
||||
await scraper.close();
|
||||
await release();
|
||||
} catch (closeError) {
|
||||
const err = closeError as Error;
|
||||
this.logger.warn(`Error closing scraper: ${err.message}`);
|
||||
this.logger.warn(`Error releasing page: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +247,7 @@ export const CATALOG_MAP: Record<string, CatalogEntry> = {
|
||||
ZFA: { code: 'CFIAT84', brand: 'Fiat' },
|
||||
ZAR: { code: 'RFIAT84', brand: 'Alfa Romeo' },
|
||||
WF0: { code: 'FORD202201', brand: 'Ford' },
|
||||
NM0: { code: 'FORD202201', brand: 'Ford' },
|
||||
JTD: { code: 'TOYOTA00', brand: 'Toyota' },
|
||||
JTE: { code: 'TOYOTA00', brand: 'Toyota' },
|
||||
SHH: { code: 'HONDA00', brand: 'Honda' },
|
||||
@@ -256,4 +257,7 @@ export const CATALOG_MAP: Record<string, CatalogEntry> = {
|
||||
WP1: { code: 'PO799', brand: 'Porsche' },
|
||||
JF1: { code: 'SUBARU201802', brand: 'Subaru' },
|
||||
JF2: { code: 'SUBARU201802', brand: 'Subaru' },
|
||||
JMZ: { code: 'MAZDA2020', brand: 'Mazda' },
|
||||
JM1: { code: 'MAZDA2020', brand: 'Mazda' },
|
||||
JM3: { code: 'MAZDA2020', brand: 'Mazda' },
|
||||
};
|
||||
|
||||
@@ -276,6 +276,34 @@ export class PL24AuthService {
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build headers for Ford legacy HTML page requests.
|
||||
* Uses text/html Accept instead of application/json.
|
||||
*/
|
||||
async buildFordLegacyHeaders(
|
||||
serviceName: string,
|
||||
): Promise<Record<string, string>> {
|
||||
const token = await this.authorizeService(serviceName);
|
||||
const sessionCookie = await this.getSessionCookie();
|
||||
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Cookie: sessionCookie,
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PL24TOKEN cookie value for Ford hintstoken parameter.
|
||||
*/
|
||||
getPL24TokenValue(): string | null {
|
||||
if (!this.tokenData?.sessionCookie) return null;
|
||||
const match = this.tokenData.sessionCookie.match(/PL24TOKEN=([^;]+)/);
|
||||
return match?.[1] || null;
|
||||
}
|
||||
|
||||
private isTokenValid(token: PL24TokenData): boolean {
|
||||
const bufferMs = 60 * 1000;
|
||||
return token.expiresAt.getTime() - bufferMs > Date.now();
|
||||
|
||||
692
apps/api/src/integrations/pl24/pl24-ford-legacy.service.ts
Normal file
692
apps/api/src/integrations/pl24/pl24-ford-legacy.service.ts
Normal file
@@ -0,0 +1,692 @@
|
||||
/**
|
||||
* Ford Legacy PL24 Service
|
||||
*
|
||||
* Handles Ford VIN decode and parts catalog via PL24's legacy .action endpoints.
|
||||
* Ford uses server-rendered HTML with embedded JavaScript variables instead of JSON APIs.
|
||||
* HTML parsing via regex + string extraction (no external deps like cheerio).
|
||||
*/
|
||||
|
||||
import { createHash } from "crypto";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PL24AuthService } from "./pl24-auth.service";
|
||||
import { RedisService } from "../../redis/redis.service";
|
||||
import { StorageService } from "../../storage/storage.service";
|
||||
import { PL24_DEFAULTS } from "./pl24.constants";
|
||||
import {
|
||||
PL24DecodedVehicle,
|
||||
PL24DecodedCategory,
|
||||
PL24PartsResponse,
|
||||
PL24Part,
|
||||
PL24MainGroup,
|
||||
SERVICE_TO_BRAND,
|
||||
} from "./pl24.types";
|
||||
import {
|
||||
FORD_LEGACY_ENDPOINTS,
|
||||
type FordPL24Support,
|
||||
} from "./pl24-ford-legacy.types";
|
||||
|
||||
@Injectable()
|
||||
export class PL24FordLegacyService {
|
||||
private readonly logger = new Logger(PL24FordLegacyService.name);
|
||||
private readonly baseUrl: string;
|
||||
private readonly timeout: number;
|
||||
private readonly language = "tr";
|
||||
|
||||
constructor(
|
||||
private readonly authService: PL24AuthService,
|
||||
private configService: ConfigService,
|
||||
private redis: RedisService,
|
||||
private storage: StorageService,
|
||||
) {
|
||||
this.baseUrl = this.configService.get<string>(
|
||||
"pl24.baseUrl",
|
||||
"https://www.partslink24.com",
|
||||
);
|
||||
this.timeout = 30000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a Ford VIN via legacy .action endpoints.
|
||||
* Returns PL24DecodedVehicle or null (triggers EMEX fallback).
|
||||
*/
|
||||
async decodeVin(vin: string): Promise<PL24DecodedVehicle | null> {
|
||||
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}vehicle:${vin}`;
|
||||
const cached = await this.redis.getJson<PL24DecodedVehicle>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
this.logger.log(`Ford legacy: decoding VIN ${vin}`);
|
||||
|
||||
try {
|
||||
// Step 1: Authorize for Ford service
|
||||
await this.authService.authorizeService("fordt_parts");
|
||||
|
||||
// Step 2: Fetch VIN group page
|
||||
const html = await this.fetchVinGroupPage(vin);
|
||||
if (!html) return null;
|
||||
|
||||
// Step 3: Check if we got demo mode (not authenticated properly)
|
||||
const support = this.extractScriptVariable<FordPL24Support>(html, "PL24_SUPPORT");
|
||||
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
|
||||
this.logger.warn("Ford legacy: got demo mode, retrying with fresh auth");
|
||||
this.authService.clearTokens();
|
||||
await this.authService.authorizeService("fordt_parts");
|
||||
|
||||
const retryHtml = await this.fetchVinGroupPage(vin);
|
||||
if (!retryHtml) return null;
|
||||
|
||||
const retrySupport = this.extractScriptVariable<FordPL24Support>(retryHtml, "PL24_SUPPORT");
|
||||
if (retrySupport?.demo || retrySupport?.role === "NOT_LOGGED_IN_DEMO") {
|
||||
this.logger.warn("Ford legacy: still demo after retry, returning null");
|
||||
return null;
|
||||
}
|
||||
return this.parseAndCacheVehicle(retryHtml, vin, cacheKey);
|
||||
}
|
||||
|
||||
return this.parseAndCacheVehicle(html, vin, cacheKey);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error(`Ford legacy VIN decode error: ${err.message}`, err.stack);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch sub-groups for a Ford legacy link path.
|
||||
*/
|
||||
async fetchSubGroupsByPath(
|
||||
linkPath: string,
|
||||
serviceName: string,
|
||||
): Promise<PL24MainGroup[]> {
|
||||
const pathHash = createHash("sha256").update(linkPath).digest("hex").substring(0, 16);
|
||||
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}ford:subgroups:${pathHash}`;
|
||||
const cached = await this.redis.getJson<PL24MainGroup[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
this.logger.log(`Ford legacy: fetching sub-groups from ${linkPath}`);
|
||||
|
||||
try {
|
||||
const html = await this.fetchFordPage(linkPath, serviceName);
|
||||
if (!html) return [];
|
||||
|
||||
const links = this.extractLinks(html, /\.action/);
|
||||
const groups: PL24MainGroup[] = links.map((link, idx) => ({
|
||||
id: String(idx),
|
||||
code: this.extractCodeFromText(link.text) || String(idx),
|
||||
name: link.text.replace(/^\d+\s+/, "").trim() || `Group ${idx}`,
|
||||
linkPath: link.href,
|
||||
}));
|
||||
|
||||
if (groups.length > 0) {
|
||||
await this.redis.setJson(cacheKey, groups, 86400);
|
||||
}
|
||||
return groups;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error(`Ford legacy sub-groups error: ${err.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch parts for a Ford legacy link path.
|
||||
*/
|
||||
async fetchPartsByPath(
|
||||
linkPath: string,
|
||||
serviceName: string,
|
||||
): Promise<PL24PartsResponse> {
|
||||
const pathHash = createHash("sha256").update(linkPath).digest("hex").substring(0, 16);
|
||||
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}ford:parts:${pathHash}`;
|
||||
const cached = await this.redis.getJson<PL24PartsResponse>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
this.logger.log(`Ford legacy: fetching parts from ${linkPath}`);
|
||||
|
||||
try {
|
||||
const html = await this.fetchFordPage(linkPath, serviceName);
|
||||
if (!html) {
|
||||
return { success: false, groupId: "", groupName: "", parts: [] };
|
||||
}
|
||||
|
||||
const parts = this.parsePartsFromHtml(html);
|
||||
const groupName = this.extractPageTitle(html);
|
||||
const schemaImageUrl = this.extractSchemaImageUrl(html);
|
||||
|
||||
const result: PL24PartsResponse = {
|
||||
success: true,
|
||||
groupId: pathHash,
|
||||
groupName,
|
||||
schemaImageUrl: schemaImageUrl || undefined,
|
||||
parts,
|
||||
};
|
||||
|
||||
if (parts.length > 0) {
|
||||
await this.redis.setJson(cacheKey, result, 3600);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error(`Ford legacy parts error: ${err.message}`);
|
||||
return { success: false, groupId: "", groupName: "", parts: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== PRIVATE: Page fetching ====================
|
||||
|
||||
/**
|
||||
* Fetch the VIN group page for a Ford VIN.
|
||||
*/
|
||||
private async fetchVinGroupPage(vin: string): Promise<string | null> {
|
||||
const token = this.authService.getPL24TokenValue();
|
||||
const params = new URLSearchParams({
|
||||
vin,
|
||||
lang: this.language,
|
||||
...(token ? { hintstoken: token } : {}),
|
||||
});
|
||||
|
||||
const url = `${this.baseUrl}${FORD_LEGACY_ENDPOINTS.VIN_GROUP}?${params}`;
|
||||
return this.fetchFordPage(url, "fordt_parts", true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a Ford legacy page (HTML). Handles 401 retry.
|
||||
*/
|
||||
private async fetchFordPage(
|
||||
url: string,
|
||||
serviceName: string,
|
||||
isFullUrl = false,
|
||||
): Promise<string | null> {
|
||||
const fullUrl = isFullUrl ? url : `${this.baseUrl}${url}`;
|
||||
const headers = await this.authService.buildFordLegacyHeaders(serviceName);
|
||||
|
||||
try {
|
||||
let response = await fetch(fullUrl, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
redirect: "follow",
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
this.logger.warn("Ford legacy: 401, refreshing auth");
|
||||
this.authService.clearTokens();
|
||||
await this.authService.authorizeService(serviceName);
|
||||
const newHeaders = await this.authService.buildFordLegacyHeaders(serviceName);
|
||||
|
||||
response = await fetch(fullUrl, {
|
||||
method: "GET",
|
||||
headers: newHeaders,
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
redirect: "follow",
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.warn(`Ford legacy: HTTP ${response.status} for ${fullUrl}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
// Some Ford endpoints may return JSON (e.g., model-config)
|
||||
if (contentType.includes("application/json")) {
|
||||
const json = await response.json();
|
||||
return JSON.stringify(json);
|
||||
}
|
||||
|
||||
return await response.text();
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error(`Ford legacy fetch error: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== PRIVATE: HTML parsing helpers ====================
|
||||
|
||||
/**
|
||||
* Extract a JavaScript variable from HTML <script> blocks.
|
||||
* Matches: window.VAR_NAME = {...}; or var VAR_NAME = {...};
|
||||
*/
|
||||
extractScriptVariable<T = unknown>(html: string, varName: string): T | null {
|
||||
// Match window.VAR = value; or var VAR = value;
|
||||
const patterns = [
|
||||
new RegExp(`window\\.${varName}\\s*=\\s*({[\\s\\S]*?});`, "m"),
|
||||
new RegExp(`window\\.${varName}\\s*=\\s*(\\[[\\s\\S]*?\\]);`, "m"),
|
||||
new RegExp(`var\\s+${varName}\\s*=\\s*({[\\s\\S]*?});`, "m"),
|
||||
new RegExp(`var\\s+${varName}\\s*=\\s*(\\[[\\s\\S]*?\\]);`, "m"),
|
||||
// Single-quoted string values
|
||||
new RegExp(`window\\.${varName}\\s*=\\s*'([^']*)'`, "m"),
|
||||
new RegExp(`window\\.${varName}\\s*=\\s*"([^"]*)"`, "m"),
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = html.match(pattern);
|
||||
if (match?.[1]) {
|
||||
try {
|
||||
// Try JSON parse first (handles objects and arrays)
|
||||
return JSON.parse(match[1]) as T;
|
||||
} catch {
|
||||
// For string values, return as-is
|
||||
return match[1] as T;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract <a> links matching a pattern from HTML.
|
||||
*/
|
||||
extractLinks(html: string, pattern: RegExp): { href: string; text: string }[] {
|
||||
const linkRegex = /<a\s+[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
|
||||
const results: { href: string; text: string }[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = linkRegex.exec(html)) !== null) {
|
||||
const href = match[1];
|
||||
const text = match[2].replace(/<[^>]+>/g, "").trim();
|
||||
if (pattern.test(href) && text) {
|
||||
results.push({ href, text });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract table rows from HTML <table> as objects.
|
||||
* Uses first row as headers.
|
||||
*/
|
||||
extractTableRows(html: string): Record<string, string>[] {
|
||||
// Find all tables
|
||||
const tableRegex = /<table[^>]*>([\s\S]*?)<\/table>/gi;
|
||||
const tableMatch = tableRegex.exec(html);
|
||||
if (!tableMatch) return [];
|
||||
|
||||
const tableHtml = tableMatch[1];
|
||||
|
||||
// Extract header cells
|
||||
const headerRegex = /<th[^>]*>([\s\S]*?)<\/th>/gi;
|
||||
const headers: string[] = [];
|
||||
let hMatch: RegExpExecArray | null;
|
||||
while ((hMatch = headerRegex.exec(tableHtml)) !== null) {
|
||||
headers.push(hMatch[1].replace(/<[^>]+>/g, "").trim());
|
||||
}
|
||||
|
||||
// Extract rows
|
||||
const rowRegex = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
|
||||
const rows: Record<string, string>[] = [];
|
||||
let rMatch: RegExpExecArray | null;
|
||||
let rowIndex = 0;
|
||||
|
||||
while ((rMatch = rowRegex.exec(tableHtml)) !== null) {
|
||||
const cellRegex = /<td[^>]*>([\s\S]*?)<\/td>/gi;
|
||||
const cells: string[] = [];
|
||||
let cMatch: RegExpExecArray | null;
|
||||
while ((cMatch = cellRegex.exec(rMatch[1])) !== null) {
|
||||
cells.push(cMatch[1].replace(/<[^>]+>/g, "").trim());
|
||||
}
|
||||
|
||||
if (cells.length > 0) {
|
||||
const row: Record<string, string> = {};
|
||||
cells.forEach((cell, idx) => {
|
||||
const key = headers[idx] || `col${idx}`;
|
||||
row[key] = cell;
|
||||
});
|
||||
rows.push(row);
|
||||
}
|
||||
rowIndex++;
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ==================== PRIVATE: Ford-specific parsing ====================
|
||||
|
||||
/**
|
||||
* Parse vehicle info + categories from VIN group HTML and cache result.
|
||||
*/
|
||||
private async parseAndCacheVehicle(
|
||||
html: string,
|
||||
vin: string,
|
||||
cacheKey: string,
|
||||
): Promise<PL24DecodedVehicle | null> {
|
||||
const vehicle = this.parseFordVehicleResponse(html, vin);
|
||||
if (!vehicle) return null;
|
||||
|
||||
const categories = this.parseFordCategories(html);
|
||||
|
||||
const result: PL24DecodedVehicle = {
|
||||
...vehicle,
|
||||
categories,
|
||||
};
|
||||
|
||||
await this.redis.setJson(cacheKey, result, 86400);
|
||||
this.logger.log(
|
||||
`Ford legacy: decoded ${vin} - ${vehicle.brand} ${vehicle.model} ${vehicle.year}, ${categories.length} categories`,
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Ford vehicle info from VIN group HTML.
|
||||
* Extracts from window.vehicles, data tables, or page content.
|
||||
*/
|
||||
private parseFordVehicleResponse(
|
||||
html: string,
|
||||
vin: string,
|
||||
): Omit<PL24DecodedVehicle, "categories"> | null {
|
||||
// Try extracting vehicle data from embedded JS
|
||||
const vehicles = this.extractScriptVariable<Array<Record<string, string>>>(html, "vehicles");
|
||||
const vehicleData = vehicles?.[0] || null;
|
||||
|
||||
// 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;
|
||||
|
||||
if (vehicleData) {
|
||||
model = vehicleData.model || vehicleData.modelName || vehicleData.description || "";
|
||||
year = 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;
|
||||
}
|
||||
|
||||
// Try to extract from page title or description
|
||||
if (!model) {
|
||||
const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
||||
if (titleMatch) {
|
||||
const title = titleMatch[1].replace(/<[^>]+>/g, "").trim();
|
||||
// Ford titles often have format: "Model Year - Parts"
|
||||
const parts = title.split(/[-–—]/);
|
||||
if (parts.length > 0) {
|
||||
model = parts[0].trim().replace(/Ford\s*/i, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try extracting from breadcrumb or header
|
||||
if (!model) {
|
||||
const headerMatch = html.match(/<h[12][^>]*>([\s\S]*?)<\/h[12]>/i);
|
||||
if (headerMatch) {
|
||||
model = headerMatch[1].replace(/<[^>]+>/g, "").trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Try extracting from vehicle info section
|
||||
if (!model || !year) {
|
||||
for (const row of tableRows) {
|
||||
const values = Object.values(row);
|
||||
const keys = Object.keys(row);
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i].toLowerCase();
|
||||
if (!model && (key.includes("model") || key.includes("arac"))) {
|
||||
model = values[i] || model;
|
||||
}
|
||||
if (!year && (key.includes("year") || key.includes("yil") || key.includes("yıl"))) {
|
||||
year = parseInt(values[i], 10) || year;
|
||||
}
|
||||
if (!engineCode && (key.includes("engine") || key.includes("motor"))) {
|
||||
engineCode = values[i] || engineCode;
|
||||
}
|
||||
if (!bodyType && (key.includes("body") || key.includes("kasa"))) {
|
||||
bodyType = values[i] || bodyType;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: year from VIN position 10
|
||||
if (!year) {
|
||||
year = this.getYearFromVin(vin);
|
||||
}
|
||||
|
||||
// If we still have nothing, at least return basic info
|
||||
if (!model && !vehicleData && tableRows.length === 0) {
|
||||
// Check if we got a valid page at all
|
||||
if (!html.includes("ford") && !html.includes("Ford")) {
|
||||
this.logger.warn("Ford legacy: page doesn't contain Ford data");
|
||||
return null;
|
||||
}
|
||||
model = "Ford";
|
||||
}
|
||||
|
||||
return {
|
||||
brand: SERVICE_TO_BRAND["fordt_parts"] || "Ford",
|
||||
model,
|
||||
year,
|
||||
series: null,
|
||||
bodyType,
|
||||
engineCode,
|
||||
engineType,
|
||||
engineVolume: null,
|
||||
transmission: null,
|
||||
driveType: null,
|
||||
colorCode: null,
|
||||
productionDate: null,
|
||||
raw: { html_length: html.length, has_vehicles_var: !!vehicleData },
|
||||
catalogInfo: {
|
||||
serviceName: "fordt_parts",
|
||||
vehicleId: vin,
|
||||
catalogPath: "/ford/fordt_parts",
|
||||
baseUrl: this.baseUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse categories from Ford VIN group HTML.
|
||||
* Categories appear as links to .action endpoints in the page.
|
||||
*/
|
||||
private parseFordCategories(html: string): PL24DecodedCategory[] {
|
||||
// Try to extract category links from the page
|
||||
// Ford categories are typically in a navigation list or table
|
||||
const categoryLinks = this.extractLinks(html, /\.action/);
|
||||
|
||||
// Filter to only category-like links (exclude navigation/auth links)
|
||||
const filtered = categoryLinks.filter((link) => {
|
||||
const href = link.href.toLowerCase();
|
||||
// Include group/category navigation links
|
||||
if (
|
||||
href.includes("group") ||
|
||||
href.includes("category") ||
|
||||
href.includes("maingroup") ||
|
||||
href.includes("parts-group")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// Exclude login, keep-alive, etc.
|
||||
if (
|
||||
href.includes("login") ||
|
||||
href.includes("keep-alive") ||
|
||||
href.includes("logout") ||
|
||||
href.includes("json-")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Include if it looks like a content link with text
|
||||
return link.text.length > 2 && href.includes(".action");
|
||||
});
|
||||
|
||||
// Deduplicate by href
|
||||
const seen = new Set<string>();
|
||||
const unique = filtered.filter((link) => {
|
||||
if (seen.has(link.href)) return false;
|
||||
seen.add(link.href);
|
||||
return true;
|
||||
});
|
||||
|
||||
return unique.map((link, idx) => {
|
||||
const code = this.extractCodeFromText(link.text) || String(idx + 1);
|
||||
const name = link.text.replace(/^\d+\s+/, "").trim();
|
||||
|
||||
return {
|
||||
code,
|
||||
nameEn: name || code,
|
||||
nameTr: name || code,
|
||||
description: null,
|
||||
iconUrl: null,
|
||||
subGroups: [],
|
||||
linkPath: link.href,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse parts from Ford HTML table.
|
||||
*/
|
||||
private parsePartsFromHtml(html: string): PL24Part[] {
|
||||
const rows = this.extractTableRows(html);
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const parts: PL24Part[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
// Try to find OEM code column (various possible names)
|
||||
const oemCode = this.findColumnValue(row, [
|
||||
"partno", "part_no", "part number", "parca no", "parça no",
|
||||
"oem", "oemcode", "code", "kod", "no",
|
||||
]);
|
||||
|
||||
if (!oemCode) continue;
|
||||
|
||||
const cleanOem = oemCode.replace(/\s+/g, "");
|
||||
const name = this.findColumnValue(row, [
|
||||
"description", "name", "aciklama", "açıklama", "tanim", "tanım",
|
||||
"descr", "part name", "parca adi", "parça adı",
|
||||
]) || "";
|
||||
|
||||
const positionCode = this.findColumnValue(row, [
|
||||
"pos", "position", "pozisyon", "no", "sira",
|
||||
]) || "";
|
||||
|
||||
const qtyStr = this.findColumnValue(row, [
|
||||
"qty", "quantity", "miktar", "adet", "count",
|
||||
]) || "";
|
||||
const quantity = parseInt(qtyStr, 10) || undefined;
|
||||
|
||||
const remark = this.findColumnValue(row, [
|
||||
"remark", "remarks", "note", "notes", "not", "aciklama2",
|
||||
]) || undefined;
|
||||
|
||||
parts.push({
|
||||
id: cleanOem,
|
||||
oemCode: cleanOem,
|
||||
formattedPartNo: oemCode,
|
||||
name,
|
||||
description: name,
|
||||
positionCode: positionCode || undefined,
|
||||
quantity,
|
||||
remark,
|
||||
});
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract schema/illustration image URL from HTML.
|
||||
*/
|
||||
private extractSchemaImageUrl(html: string): string | null {
|
||||
// Look for illustration images
|
||||
const imgPatterns = [
|
||||
/<img[^>]+src=["']([^"']*(?:illustration|schema|diagram|exploded)[^"']*)["']/i,
|
||||
/<img[^>]+src=["']([^"']*(?:\.png|\.jpg|\.gif|\.svg)[^"']*)["'][^>]*class=["'][^"']*(?:schema|illus|diagram)/i,
|
||||
/<img[^>]+id=["'](?:schema|illustration|diagram)["'][^>]*src=["']([^"']+)["']/i,
|
||||
];
|
||||
|
||||
for (const pattern of imgPatterns) {
|
||||
const match = html.match(pattern);
|
||||
if (match?.[1]) {
|
||||
const src = match[1];
|
||||
return src.startsWith("http") ? src : `${this.baseUrl}${src}`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract page title from HTML.
|
||||
*/
|
||||
private extractPageTitle(html: string): string {
|
||||
const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
||||
if (titleMatch) {
|
||||
return titleMatch[1].replace(/<[^>]+>/g, "").trim();
|
||||
}
|
||||
|
||||
const h1Match = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
|
||||
if (h1Match) {
|
||||
return h1Match[1].replace(/<[^>]+>/g, "").trim();
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
// ==================== PRIVATE: Utility ====================
|
||||
|
||||
/**
|
||||
* Find a column value in a row by trying multiple possible column names.
|
||||
*/
|
||||
private findColumnValue(
|
||||
row: Record<string, string>,
|
||||
possibleKeys: string[],
|
||||
): string | null {
|
||||
// Try exact match first
|
||||
for (const key of possibleKeys) {
|
||||
if (row[key]) return row[key];
|
||||
}
|
||||
|
||||
// Try case-insensitive match
|
||||
const rowKeys = Object.keys(row);
|
||||
for (const key of possibleKeys) {
|
||||
const found = rowKeys.find(
|
||||
(k) => k.toLowerCase() === key.toLowerCase(),
|
||||
);
|
||||
if (found && row[found]) return row[found];
|
||||
}
|
||||
|
||||
// Try partial match
|
||||
for (const key of possibleKeys) {
|
||||
const found = rowKeys.find(
|
||||
(k) => k.toLowerCase().includes(key.toLowerCase()),
|
||||
);
|
||||
if (found && row[found]) return row[found];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract numeric code from category text (e.g., "01 Engine" → "01").
|
||||
*/
|
||||
private extractCodeFromText(text: string): string {
|
||||
const match = text.match(/^(\d+)\s/);
|
||||
return match?.[1] || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get year from VIN position 10.
|
||||
*/
|
||||
private getYearFromVin(vin: string): number {
|
||||
if (!vin || vin.length < 10) return 0;
|
||||
const yearChar = vin.charAt(9).toUpperCase();
|
||||
const yearMap: Record<string, number> = {
|
||||
"1": 2001, "2": 2002, "3": 2003, "4": 2004, "5": 2005,
|
||||
"6": 2006, "7": 2007, "8": 2008, "9": 2009,
|
||||
A: 2010, B: 2011, C: 2012, D: 2013, E: 2014, F: 2015,
|
||||
G: 2016, H: 2017, J: 2018, K: 2019, L: 2020, M: 2021,
|
||||
N: 2022, P: 2023, R: 2024, S: 2025, T: 2026, V: 2027,
|
||||
W: 2028, X: 2029, Y: 2030,
|
||||
};
|
||||
return yearMap[yearChar] || 0;
|
||||
}
|
||||
}
|
||||
38
apps/api/src/integrations/pl24/pl24-ford-legacy.types.ts
Normal file
38
apps/api/src/integrations/pl24/pl24-ford-legacy.types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Ford Legacy PL24 Types
|
||||
*
|
||||
* Ford uses a legacy JSP/Struts architecture on PL24 with .action endpoints
|
||||
* and server-rendered HTML containing embedded JavaScript variables.
|
||||
*/
|
||||
|
||||
export interface FordPL24Support {
|
||||
role: string; // "NOT_LOGGED_IN_DEMO" or authenticated role
|
||||
locale: string; // "tr"
|
||||
mode: string; // e.g. "A0LW0TRTR"
|
||||
action: string;
|
||||
contextPath: string; // "/ford"
|
||||
demo: boolean;
|
||||
}
|
||||
|
||||
export interface FordVehicleInfo {
|
||||
vin: string;
|
||||
brand: string;
|
||||
model: string;
|
||||
year: number;
|
||||
bodyType: string | null;
|
||||
engineCode: string | null;
|
||||
engineType: string | null;
|
||||
}
|
||||
|
||||
export interface FordCategoryLink {
|
||||
href: string;
|
||||
text: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export const FORD_LEGACY_ENDPOINTS = {
|
||||
VIN_GROUP: "/ford/fordt_parts/vin-group.action",
|
||||
VEHICLE: "/ford/vehicle.action",
|
||||
MODEL_CONFIG: "/ford/json-model-config.action",
|
||||
KEEP_ALIVE: "/ford/json-keep-alive.action",
|
||||
} as const;
|
||||
@@ -15,4 +15,10 @@ export const PL24_ENDPOINTS = {
|
||||
|
||||
// Image server
|
||||
IMAGESERVER: "/imageserver/ext/api/images",
|
||||
|
||||
// Ford Legacy (.action endpoints)
|
||||
FORD_VIN_GROUP: "/ford/fordt_parts/vin-group.action",
|
||||
FORD_VEHICLE: "/ford/vehicle.action",
|
||||
FORD_MODEL_CONFIG: "/ford/json-model-config.action",
|
||||
FORD_KEEP_ALIVE: "/ford/json-keep-alive.action",
|
||||
} as const;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PL24Service } from "./pl24.service";
|
||||
import { PL24AuthService } from "./pl24-auth.service";
|
||||
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
|
||||
|
||||
@Module({
|
||||
providers: [PL24Service, PL24AuthService],
|
||||
exports: [PL24Service, PL24AuthService],
|
||||
providers: [PL24Service, PL24AuthService, PL24FordLegacyService],
|
||||
exports: [PL24Service, PL24AuthService, PL24FordLegacyService],
|
||||
})
|
||||
export class PL24Module {}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PL24AuthService } from "./pl24-auth.service";
|
||||
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
|
||||
import { RedisService } from "../../redis/redis.service";
|
||||
import { StorageService } from "../../storage/storage.service";
|
||||
import { PL24_DEFAULTS } from "./pl24.constants";
|
||||
@@ -41,6 +42,7 @@ export class PL24Service {
|
||||
|
||||
constructor(
|
||||
private readonly authService: PL24AuthService,
|
||||
private readonly fordLegacyService: PL24FordLegacyService,
|
||||
private configService: ConfigService,
|
||||
private redis: RedisService,
|
||||
private storage: StorageService,
|
||||
@@ -74,8 +76,11 @@ export class PL24Service {
|
||||
);
|
||||
}
|
||||
|
||||
// Only P5 Modern is supported for now
|
||||
// Dispatch legacy architectures to their dedicated services
|
||||
if (!isP5Modern(serviceName)) {
|
||||
if (serviceName === "fordt_parts") {
|
||||
return this.fordLegacyService.decodeVin(cleanVin);
|
||||
}
|
||||
this.logger.warn(
|
||||
`Legacy architecture not supported yet: ${serviceName}`,
|
||||
);
|
||||
@@ -251,6 +256,11 @@ export class PL24Service {
|
||||
linkPath: string,
|
||||
serviceName: string,
|
||||
): Promise<PL24PartsResponse> {
|
||||
// Ford legacy dispatch
|
||||
if (this.isFordLegacyPath(linkPath)) {
|
||||
return this.fordLegacyService.fetchPartsByPath(linkPath, serviceName);
|
||||
}
|
||||
|
||||
const pathHash = createHash("sha256").update(linkPath).digest("hex").substring(0, 16);
|
||||
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}parts:path:${pathHash}`;
|
||||
const cached = await this.redis.getJson<PL24PartsResponse>(cacheKey);
|
||||
@@ -368,6 +378,11 @@ export class PL24Service {
|
||||
linkPath: string,
|
||||
serviceName: string,
|
||||
): Promise<PL24MainGroup[]> {
|
||||
// Ford legacy dispatch
|
||||
if (this.isFordLegacyPath(linkPath)) {
|
||||
return this.fordLegacyService.fetchSubGroupsByPath(linkPath, serviceName);
|
||||
}
|
||||
|
||||
this.logger.log(`Fetching sub-groups by path: ${linkPath}`);
|
||||
|
||||
try {
|
||||
@@ -530,7 +545,8 @@ export class PL24Service {
|
||||
isSupported(vin: string): boolean {
|
||||
if (!vin || vin.length < 3) return false;
|
||||
const serviceName = this.getServiceName(vin);
|
||||
return !!serviceName && isP5Modern(serviceName);
|
||||
if (!serviceName) return false;
|
||||
return isP5Modern(serviceName) || serviceName === "fordt_parts";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -540,13 +556,22 @@ export class PL24Service {
|
||||
const brands = new Set<string>();
|
||||
for (const service of Object.values(PL24_WMI_SERVICE_MAP)) {
|
||||
const brand = SERVICE_TO_BRAND[service];
|
||||
if (brand && isP5Modern(service)) {
|
||||
if (brand && (isP5Modern(service) || service === "fordt_parts")) {
|
||||
brands.add(brand);
|
||||
}
|
||||
}
|
||||
return Array.from(brands).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get brand display name from VIN's WMI.
|
||||
*/
|
||||
getBrandName(vin: string): string | null {
|
||||
const serviceName = this.getServiceName(vin);
|
||||
if (!serviceName) return null;
|
||||
return SERVICE_TO_BRAND[serviceName] || null;
|
||||
}
|
||||
|
||||
// ==================== PRIVATE: Request helpers ====================
|
||||
|
||||
/**
|
||||
@@ -861,6 +886,7 @@ export class PL24Service {
|
||||
const responseData = response as Record<string, unknown>;
|
||||
|
||||
let records: Array<Record<string, unknown>> = [];
|
||||
let bomBasePath: string | undefined;
|
||||
|
||||
if (Array.isArray(response)) {
|
||||
records = response;
|
||||
@@ -871,6 +897,11 @@ export class PL24Service {
|
||||
} else if (Array.isArray(innerData)) {
|
||||
records = innerData as unknown as Array<Record<string, unknown>>;
|
||||
}
|
||||
// Servicepart: extract bomBaseLink for constructing child paths
|
||||
const bomBaseLink = innerData.bomBaseLink as Record<string, unknown> | undefined;
|
||||
if (bomBaseLink?.path) {
|
||||
bomBasePath = bomBaseLink.path as string;
|
||||
}
|
||||
} else if (responseData.subGroups) {
|
||||
records = responseData.subGroups as Array<Record<string, unknown>>;
|
||||
} else if (responseData.groups) {
|
||||
@@ -879,9 +910,11 @@ export class PL24Service {
|
||||
|
||||
const availableRecords = records.filter((record) => {
|
||||
if (record.unavailable) return false;
|
||||
// Filter out illustration headers that have no navigable link
|
||||
const link = (record.link as Record<string, unknown>) || {};
|
||||
if (!link.path) return false;
|
||||
// Servicepart records have no link.path but can use bomBaseLink
|
||||
if (!link.path && !bomBasePath) return false;
|
||||
// Skip the "all" pseudo-record in servicepart responses
|
||||
if (record.id === "all") return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -924,6 +957,12 @@ export class PL24Service {
|
||||
|
||||
if (!name) name = code;
|
||||
|
||||
// Construct linkPath: use record's own link.path, or bomBaseLink + record id
|
||||
const recordLinkPath = (link.path as string) || undefined;
|
||||
const constructedPath = !recordLinkPath && bomBasePath
|
||||
? `${bomBasePath}${record.id}`
|
||||
: recordLinkPath;
|
||||
|
||||
return {
|
||||
id: String(record.id || ""),
|
||||
code,
|
||||
@@ -931,8 +970,8 @@ export class PL24Service {
|
||||
description: values.modelDescriptions || undefined,
|
||||
imageUrl: undefined,
|
||||
partCount: undefined,
|
||||
linkPath: (link.path as string) || undefined,
|
||||
linkWid: (link.wid as string) || undefined,
|
||||
linkPath: constructedPath,
|
||||
linkWid: (link.wid as string) || (bomBasePath ? "servicePartsItemsTable" : undefined),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1081,7 +1120,7 @@ export class PL24Service {
|
||||
if (standardMatch) return standardMatch[1];
|
||||
|
||||
const tiffMatch = imageUrl.match(
|
||||
/\/tiffimages\/[^/]+\/[^/]+\/([a-zA-Z0-9]+)\.\w+/,
|
||||
/\/tiffimages\/(?:[^/]+\/)+([a-zA-Z0-9_-]+)\.\w+/,
|
||||
);
|
||||
if (tiffMatch) return tiffMatch[1];
|
||||
|
||||
@@ -1094,21 +1133,35 @@ export class PL24Service {
|
||||
// ==================== PRIVATE: Brand-specific flows ====================
|
||||
|
||||
/**
|
||||
* Convert partinfo links to bom links.
|
||||
* Convert partinfo links to bom/bomdetails links.
|
||||
* partinfo returns single part detail; bom returns full illustration + all parts.
|
||||
*
|
||||
* Standard brands: /extern/partinfo/vin → /extern/bom/vin
|
||||
* Suzuki-style: /extern/partinfo/vin → /extern/details/vin/bomdetails
|
||||
*/
|
||||
private convertPartInfoToBom(linkPath: string): string {
|
||||
if (!linkPath.includes("/partinfo/")) return linkPath;
|
||||
|
||||
const bomPath = linkPath.replace("/partinfo/", "/bom/");
|
||||
// Suzuki (and similar) uses /details/vin/bomdetails instead of /bom/
|
||||
const isSuzukiStyle = linkPath.includes("/p5suzuki/");
|
||||
const bomPath = isSuzukiStyle
|
||||
? linkPath.replace("/partinfo/vin", "/details/vin/bomdetails")
|
||||
: linkPath.replace("/partinfo/", "/bom/");
|
||||
|
||||
const url = new URL(bomPath, "http://placeholder");
|
||||
// Remove partinfo-specific params, but keep illustration info for correct BOM context
|
||||
// Remove partinfo-specific params
|
||||
url.searchParams.delete("fiValidity");
|
||||
url.searchParams.delete("position");
|
||||
url.searchParams.delete("positionId");
|
||||
url.searchParams.delete("partno");
|
||||
url.searchParams.delete("pos");
|
||||
return `${url.pathname}?${url.searchParams.toString()}`;
|
||||
}
|
||||
|
||||
private isFordLegacyPath(linkPath: string): boolean {
|
||||
return linkPath.includes("/ford/") && linkPath.includes(".action");
|
||||
}
|
||||
|
||||
private isDaimlerService(serviceName: string): boolean {
|
||||
return (
|
||||
serviceName.startsWith("mercedes") || serviceName === "smart_parts"
|
||||
|
||||
@@ -286,6 +286,13 @@ export const PL24_SERVICE_CATALOGS: Record<string, PL24CatalogConfig> = {
|
||||
apiPath: "/p5suzuki",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
|
||||
// Ford
|
||||
fordt_parts: {
|
||||
basePath: "/ford/fordt_parts",
|
||||
apiPath: "/ford/fordt_parts",
|
||||
architecture: "LEGACY_FORD",
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== HELPER FUNCTIONS ====================
|
||||
@@ -428,6 +435,12 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
|
||||
TSM: "suzuki_parts",
|
||||
MA3: "suzuki_parts",
|
||||
MBH: "suzuki_parts",
|
||||
|
||||
// Ford
|
||||
NM0: "fordt_parts",
|
||||
WF0: "fordt_parts",
|
||||
"1FA": "fordt_parts",
|
||||
"3FA": "fordt_parts",
|
||||
};
|
||||
|
||||
// ==================== VEHICLE TYPES ====================
|
||||
@@ -604,4 +617,5 @@ export const SERVICE_TO_BRAND: Record<string, string> = {
|
||||
man_parts: "MAN",
|
||||
mmc_parts: "Mitsubishi",
|
||||
suzuki_parts: "Suzuki",
|
||||
fordt_parts: "Ford",
|
||||
};
|
||||
|
||||
@@ -63,7 +63,10 @@ export class PartsService {
|
||||
description: p.description || null,
|
||||
quantity: p.quantity || null,
|
||||
position: p.positionCode || null,
|
||||
hotspotIndex: p.hotspotId ? parseInt(p.hotspotId, 10) || null : null,
|
||||
hotspotIndex: p.hotspotId ? (() => {
|
||||
const val = parseInt(p.hotspotId!, 10);
|
||||
return (val > 0 && val <= 2147483647) ? val : null;
|
||||
})() : null,
|
||||
source: "pl24" as const,
|
||||
}));
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { DATABASE, Database } from "../database/database.provider";
|
||||
import { vehicles, queryLogs, brands, userBrands, userSubscriptions } from "../database/schema/core";
|
||||
import { vehicles, queryLogs, brands, userBrands, userSubscriptions, plans } from "../database/schema/core";
|
||||
import { CorgiService } from "../integrations/corgi/corgi.service";
|
||||
import { PL24Service } from "../integrations/pl24/pl24.service";
|
||||
import { VinApiService } from "../integrations/vin-api/vin-api.service";
|
||||
@@ -53,32 +53,50 @@ export class VehiclesService {
|
||||
|
||||
// 2. Corgi decode (offline)
|
||||
const corgiResult = this.corgiService.decodeVin(vin);
|
||||
if (!corgiResult || !corgiResult.isKnown) {
|
||||
await this.logQuery(userId, vin, null, "corgi", false, Date.now() - startTime, "Unknown VIN/brand");
|
||||
throw new BadRequestException("VIN not recognized. Brand not supported.");
|
||||
const corgiKnown = corgiResult && corgiResult.isKnown;
|
||||
|
||||
// 3. Brand access check (only if Corgi recognized the brand)
|
||||
let brandId: string | null = null;
|
||||
let brandName: string | null = null;
|
||||
if (corgiKnown) {
|
||||
const brand = await this.db
|
||||
.select()
|
||||
.from(brands)
|
||||
.where(eq(brands.name, corgiResult.brandName))
|
||||
.limit(1);
|
||||
|
||||
if (brand.length > 0) {
|
||||
brandId = brand[0].id;
|
||||
brandName = corgiResult.brandName;
|
||||
await this.checkBrandAccess(userId, brandId);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Brand access check
|
||||
const brand = await this.db
|
||||
.select()
|
||||
.from(brands)
|
||||
.where(eq(brands.name, corgiResult.brandName))
|
||||
.limit(1);
|
||||
|
||||
if (brand.length === 0) {
|
||||
throw new BadRequestException(`Brand not supported: ${corgiResult.brandName}`);
|
||||
}
|
||||
|
||||
const brandId = brand[0].id;
|
||||
await this.checkBrandAccess(userId, brandId);
|
||||
|
||||
// 4. PL24 decode (real API)
|
||||
// 4. PL24 decode (real API) — always attempt, PL24 has its own WMI map
|
||||
let source = "corgi";
|
||||
let pl24Vehicle = null;
|
||||
try {
|
||||
pl24Vehicle = await this.pl24Service.decodeVin(vin);
|
||||
} catch (err) {
|
||||
this.logger.warn(`PL24 decode failed for ${vin}: ${(err as Error).message}`);
|
||||
if (this.pl24Service.isSupported(vin)) {
|
||||
try {
|
||||
pl24Vehicle = await this.pl24Service.decodeVin(vin);
|
||||
// If Corgi didn't know the brand, resolve it from PL24's WMI map
|
||||
if (!brandId && pl24Vehicle) {
|
||||
const pl24Brand = this.pl24Service.getBrandName(vin);
|
||||
if (pl24Brand) {
|
||||
const brand = await this.db
|
||||
.select()
|
||||
.from(brands)
|
||||
.where(eq(brands.name, pl24Brand))
|
||||
.limit(1);
|
||||
if (brand.length > 0) {
|
||||
brandId = brand[0].id;
|
||||
brandName = pl24Brand;
|
||||
await this.checkBrandAccess(userId, brandId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`PL24 decode failed for ${vin}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Fallback to EMEX if PL24 not available
|
||||
@@ -86,10 +104,19 @@ export class VehiclesService {
|
||||
if (!pl24Vehicle) {
|
||||
this.logger.log(`PL24 returned no data for ${vin}, trying EMEX fallback`);
|
||||
try {
|
||||
if (this.emexService.isSupported(vin)) {
|
||||
const emexResult = await this.emexService.decodeVin(vin);
|
||||
if (emexResult && emexResult.brand !== 'UNKNOWN') {
|
||||
emexVehicle = emexResult;
|
||||
const emexResult = await this.emexService.decodeVin(vin);
|
||||
if (emexResult && emexResult.brand !== 'UNKNOWN') {
|
||||
emexVehicle = emexResult;
|
||||
if (!brandId && emexResult.brand) {
|
||||
const emexBrand = await this.db
|
||||
.select()
|
||||
.from(brands)
|
||||
.where(eq(brands.name, emexResult.brand))
|
||||
.limit(1);
|
||||
if (emexBrand.length > 0) {
|
||||
brandId = emexBrand[0].id;
|
||||
brandName = emexResult.brand;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (emexError) {
|
||||
@@ -97,6 +124,12 @@ export class VehiclesService {
|
||||
}
|
||||
}
|
||||
|
||||
// If nothing recognized this VIN at all, give up
|
||||
if (!pl24Vehicle && !emexVehicle && !corgiKnown) {
|
||||
await this.logQuery(userId, vin, null, "corgi", false, Date.now() - startTime, "Unknown VIN/brand");
|
||||
throw new BadRequestException("VIN not recognized. Brand not supported.");
|
||||
}
|
||||
|
||||
// 6. Fallback to VIN API if PL24 and EMEX not available
|
||||
let vinApiData: any = null;
|
||||
if (!pl24Vehicle && !emexVehicle) {
|
||||
@@ -113,9 +146,9 @@ export class VehiclesService {
|
||||
userId,
|
||||
vin,
|
||||
brandId,
|
||||
brandName: corgiResult.brandName,
|
||||
brandName: brandName || corgiResult?.brandName || emexVehicle?.brand || null,
|
||||
model: pl24Vehicle?.model || emexVehicle?.model || vinApiData?.model || null,
|
||||
year: pl24Vehicle?.year || emexVehicle?.year || corgiResult.modelYear || (vinApiData ? parseInt(vinApiData.modelYear) : null),
|
||||
year: pl24Vehicle?.year || emexVehicle?.year || corgiResult?.modelYear || (vinApiData ? parseInt(vinApiData.modelYear) : null),
|
||||
engine: pl24Vehicle?.engineType || pl24Vehicle?.engineCode || emexVehicle?.engineCode || emexVehicle?.engineType || vinApiData?.engineModel || null,
|
||||
transmission: pl24Vehicle?.transmission || emexVehicle?.transmission || vinApiData?.transmissionStyle || null,
|
||||
bodyType: pl24Vehicle?.bodyType || emexVehicle?.bodyType || vinApiData?.bodyClass || null,
|
||||
@@ -177,8 +210,12 @@ export class VehiclesService {
|
||||
|
||||
private async checkBrandAccess(userId: string, brandId: string) {
|
||||
const [sub] = await this.db
|
||||
.select()
|
||||
.select({
|
||||
id: userSubscriptions.id,
|
||||
brandCount: plans.brandCount,
|
||||
})
|
||||
.from(userSubscriptions)
|
||||
.innerJoin(plans, eq(userSubscriptions.planId, plans.id))
|
||||
.where(and(eq(userSubscriptions.userId, userId), eq(userSubscriptions.status, "active")))
|
||||
.limit(1);
|
||||
|
||||
@@ -186,6 +223,9 @@ export class VehiclesService {
|
||||
throw new ForbiddenException("No active subscription. Please subscribe to access vehicle data.");
|
||||
}
|
||||
|
||||
// brandCount === 0 means unlimited (Full Paket) — skip per-brand check
|
||||
if (sub.brandCount === 0) return;
|
||||
|
||||
const [access] = await this.db
|
||||
.select()
|
||||
.from(userBrands)
|
||||
|
||||
@@ -2,10 +2,12 @@ const STORAGE_KEY = "userSettings";
|
||||
|
||||
interface UserSettings {
|
||||
categoryViewMode?: "grid" | "tree";
|
||||
sidebarCollapsed?: boolean;
|
||||
}
|
||||
|
||||
const defaults: UserSettings = {
|
||||
categoryViewMode: "grid",
|
||||
sidebarCollapsed: false,
|
||||
};
|
||||
|
||||
export function getUserSettings(): UserSettings {
|
||||
|
||||
@@ -17,8 +17,11 @@ import {
|
||||
Menu,
|
||||
X,
|
||||
LogOut,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||
|
||||
export const Route = createFileRoute("/dashboard")({
|
||||
component: DashboardLayout,
|
||||
@@ -45,6 +48,13 @@ function DashboardLayout() {
|
||||
const { user, isLoading, signOut, isAdmin } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(() => getUserSettings().sidebarCollapsed ?? false);
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
const next = !collapsed;
|
||||
setCollapsed(next);
|
||||
setUserSetting("sidebarCollapsed", next);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -70,25 +80,41 @@ function DashboardLayout() {
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Desktop Sidebar */}
|
||||
<aside className="hidden w-64 flex-shrink-0 border-r lg:block">
|
||||
<aside
|
||||
className={`hidden flex-shrink-0 border-r transition-[width] duration-200 lg:block ${collapsed ? "w-16" : "w-64"}`}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex h-16 items-center border-b px-6">
|
||||
<Link to="/" className="text-xl font-bold">
|
||||
Sase.tr
|
||||
</Link>
|
||||
<div className={`flex h-16 items-center border-b ${collapsed ? "justify-center px-2" : "justify-between px-4"}`}>
|
||||
{!collapsed && (
|
||||
<Link to="/" className="text-xl font-bold">
|
||||
Sase.tr
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleCollapsed}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{collapsed ? (
|
||||
<PanelLeftOpen className="h-5 w-5" />
|
||||
) : (
|
||||
<PanelLeftClose className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-1 p-4">
|
||||
<nav className={`flex-1 space-y-1 overflow-y-auto ${collapsed ? "p-2" : "p-4"}`}>
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors hover:bg-accent [&.active]:bg-accent [&.active]:text-accent-foreground"
|
||||
title={collapsed ? t(item.label) : undefined}
|
||||
className={`flex items-center rounded-lg text-sm font-medium transition-colors hover:bg-accent [&.active]:bg-accent [&.active]:text-accent-foreground ${collapsed ? "justify-center p-2" : "gap-3 px-3 py-2"}`}
|
||||
activeProps={{ className: "active" }}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{t(item.label)}
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{t(item.label)}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
@@ -99,37 +125,52 @@ function DashboardLayout() {
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors hover:bg-accent [&.active]:bg-accent [&.active]:text-accent-foreground"
|
||||
title={collapsed ? item.label : undefined}
|
||||
className={`flex items-center rounded-lg text-sm font-medium transition-colors hover:bg-accent [&.active]:bg-accent [&.active]:text-accent-foreground ${collapsed ? "justify-center p-2" : "gap-3 px-3 py-2"}`}
|
||||
activeProps={{ className: "active" }}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{/* User section - bottom */}
|
||||
<div className={`border-t ${collapsed ? "p-2" : "p-3"}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => signOut()}
|
||||
title={collapsed ? user.name ?? "Çıkış" : undefined}
|
||||
className={`flex w-full items-center rounded-lg text-left transition-colors hover:bg-accent ${collapsed ? "justify-center p-2" : "gap-3 px-3 py-2"}`}
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
|
||||
{user.name?.charAt(0).toUpperCase() ?? "?"}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{user.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
<LogOut className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex flex-1 flex-col">
|
||||
{/* Header */}
|
||||
<header className="flex h-16 items-center justify-between border-b px-6">
|
||||
<header className="flex h-16 items-center border-b px-6 lg:hidden">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="lg:hidden"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">{user.name}</span>
|
||||
<Button variant="ghost" size="icon" onClick={() => signOut()}>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page Content */}
|
||||
@@ -148,14 +189,14 @@ function DashboardLayout() {
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<aside className="absolute left-0 top-0 h-full w-64 bg-background shadow-lg">
|
||||
<aside className="absolute left-0 top-0 flex h-full w-64 flex-col bg-background shadow-lg">
|
||||
<div className="flex h-16 items-center justify-between border-b px-6">
|
||||
<span className="text-xl font-bold">Sase.tr</span>
|
||||
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<nav className="space-y-1 p-4">
|
||||
<nav className="flex-1 space-y-1 overflow-y-auto p-4">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
@@ -188,6 +229,26 @@ function DashboardLayout() {
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{/* User section - bottom */}
|
||||
<div className="border-t p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
signOut();
|
||||
setMobileOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors hover:bg-accent"
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
|
||||
{user.name?.charAt(0).toUpperCase() ?? "?"}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{user.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
<LogOut className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Input } from "@sase/ui";
|
||||
import { Card, CardContent } from "@sase/ui";
|
||||
@@ -22,6 +23,12 @@ function SearchPage() {
|
||||
const [vin, setVin] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
const { data: history } = useQuery({
|
||||
queryKey: ["vehicles", "history"],
|
||||
queryFn: () => api.get<any[]>("/vehicles/history?limit=20"),
|
||||
});
|
||||
|
||||
async function handleSearch(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -61,16 +68,40 @@ function SearchPage() {
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={handleSearch} className="flex gap-3">
|
||||
<Input
|
||||
placeholder="VIN numarasını girin (17 karakter)"
|
||||
value={vin}
|
||||
onChange={(e) => {
|
||||
setVin(e.target.value.toUpperCase());
|
||||
setError(null);
|
||||
}}
|
||||
maxLength={17}
|
||||
className="font-mono text-lg tracking-wider"
|
||||
/>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
placeholder="VIN numarasını girin (17 karakter)"
|
||||
value={vin}
|
||||
onChange={(e) => {
|
||||
setVin(e.target.value.toUpperCase());
|
||||
setError(null);
|
||||
}}
|
||||
onFocus={() => setShowHistory(true)}
|
||||
onBlur={() => setShowHistory(false)}
|
||||
maxLength={17}
|
||||
className="font-mono text-lg tracking-wider"
|
||||
/>
|
||||
{showHistory && vin.length === 0 && history && history.length > 0 && (
|
||||
<div className="absolute z-50 mt-1 max-h-80 w-full overflow-y-auto rounded-md border bg-popover shadow-md">
|
||||
{history.map((v: any) => (
|
||||
<div
|
||||
key={v.id}
|
||||
className="flex cursor-pointer items-center justify-between px-3 py-2 hover:bg-accent"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setVin(v.vin);
|
||||
setShowHistory(false);
|
||||
}}
|
||||
>
|
||||
<span className="font-mono text-sm">{v.vin}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{v.brandName} {v.model} {v.year}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" disabled={loading || vin.length !== 17}>
|
||||
{loading ? (
|
||||
<span className="animate-spin">...</span>
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user