feat: add parts catalogs integration, catalog prefetch worker, and vehicle select modal

Integrate external parts catalogs API with auth service, add BullMQ-based
catalog prefetch worker for background data caching, expand vehicles service
with shared vehicle support, and add vehicle select modal to frontend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sase Dev
2026-02-20 18:14:36 +00:00
parent f3f3f57756
commit f1a27810db
32 changed files with 2591 additions and 118 deletions

View File

@@ -28,6 +28,7 @@ import {
} from './emex.types';
import { mapEmexResponse, createEmptyDecodedVehicle } from './emex.mapper';
import { EmexBrowserService } from './emex.browser';
import { RedisService } from '../../redis/redis.service';
// Type definition for the imported scraper module
interface EmexScraperModule {
@@ -66,6 +67,7 @@ export class EmexService {
constructor(
private configService: ConfigService,
private browserService: EmexBrowserService,
private redis: RedisService,
) {
// __dirname is apps/api/src/integrations/emex/ or dist/integrations/emex/
// Scraper lives at <monorepo-root>/scripts/emex-vin-scraper.js
@@ -82,6 +84,15 @@ export class EmexService {
this.logger.log(`EMEX Service initialized with scraper path: ${this.scraperPath}`);
}
/** Mark EMEX as actively used (5min TTL) to defer prefetch worker */
private async touchActivity(): Promise<void> {
try {
await this.redis.set("prefetch:activity:emex", String(Date.now()), 300);
} catch {
// Non-critical — don't break the request
}
}
/**
* Lazily initialize the scraper module
*/
@@ -184,6 +195,7 @@ export class EmexService {
const supported = this.isSupported(cleanVin);
this.logger.log(`Decoding VIN: ${cleanVin} (catalog supported: ${supported})`);
await this.touchActivity();
let release: (() => Promise<void>) | null = null;
@@ -375,6 +387,7 @@ export class EmexService {
}
this.logger.log(`Fetching parts from category URL: ${categoryUrl}`);
await this.touchActivity();
let release: (() => Promise<void>) | null = null;

View File

@@ -0,0 +1,685 @@
/**
* Parts-Catalogs Auth Service — JWT warm pool via Playwright + DataImpulse proxy
*
* JWT is captured by navigating to partner sites and intercepting
* the Authorization header from requests to parts-catalogs.com.
* JWT is IP-bound (~10 min TTL), so the same proxy port must be used for both
* browser capture and subsequent API calls.
*
* Warm pool behavior:
* 09:00-19:00 Istanbul → proactive: maintain >= 1 slot, auto-refresh before expiry
* 19:00-09:00 → on-demand only: capture only when needed
*
* Each slot manages its own refresh timer (no polling loop).
* Dynamic scaling: 1 JWT per 6 req/min, capped at 5 slots.
*/
import {
Injectable,
Logger,
OnModuleInit,
OnModuleDestroy,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import type { Browser, BrowserContext } from "playwright";
import type { PcatJwtToken, JwtSlot, PcatSession } from "./parts-catalogs.types";
const REFRESH_BUFFER = 90; // Refresh 90s before expiry
const CAPTURE_POLL_INTERVAL = 500; // ms
const CAPTURE_POLL_MAX = 40; // 40 × 500ms = 20s max wait
const PAGE_TIMEOUT = 30_000; // 30s navigation timeout
const CONTEXT_CLOSE_TIMEOUT = 5_000;
const SITE_COOLDOWN = 10 * 60 * 1000; // 10 min per site
const MAX_POOL_SIZE = 5;
const RPM_WINDOW = 60_000; // 1-minute rolling window
const RPM_PER_SLOT = 6; // 1 JWT per 6 req/min
/**
* Sites that embed the parts-catalogs.com widget.
* Widget loads JS → calls /api/start → then calls /v1/catalogs/ with JWT.
* Each site uses a different proxy port (IP) to avoid rate limiting.
*/
const JWT_SITES = [
"https://www.e-acca.com/cats/#/catalogs",
"https://www.alkatalog.com/cats/#/catalogs",
"https://auto-komplekt.ru/goodvin#/catalogs",
"https://www.autotrade.md/cats/#/catalogs",
"https://www.e-trak.ru/cats/#/catalogs",
"https://www.autopolyus.ru/cats/#/catalogs",
"https://knkauto.ru/goodvin#/catalogs",
"https://www.autodo.kz/#/catalogs",
"https://avtoman124.ru/goodvin#/catalogs",
"https://flynestauto.com/auto-parts-oem-catalog",
"http://en.demo.tradesoft.hk.com/cats/#/catalogs",
];
// DataImpulse proxy defaults (port-based IP rotation)
const DI_HOST = "gw.dataimpulse.com";
const DI_PORT_MIN = 10000;
const DI_PORT_MAX = 10999;
const DI_DEFAULT_USER = "1726bbe361918676d44e";
const DI_DEFAULT_PASS = "f11c7b6128cc86c6";
/** Simple counting semaphore (same pattern as EmexBrowserService) */
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();
} else {
this.current--;
}
}
}
@Injectable()
export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PartsCatalogsAuthService.name);
private browser: Browser | null = null;
private launching: Promise<void> | null = null;
private readonly semaphore = new Semaphore(1); // Max 1 concurrent JWT capture
// Pool state
private pool: JwtSlot[] = [];
private siteLastUsedAt = new Map<string, number>();
private siteIndex = 0; // round-robin across JWT_SITES
private requestRoundRobin = 0; // round-robin across pool slots
// Business hours scheduling
private businessHoursTimer: ReturnType<typeof setTimeout> | null = null;
// RPM tracking
private requestTimestamps: number[] = [];
// Config
private readonly useProxy: boolean;
private readonly proxyHost: string;
private readonly proxyUser: string;
private readonly proxyPass: string;
constructor(private configService: ConfigService) {
this.useProxy =
this.configService.get<string>("PCAT_USE_PROXY", "true") === "true";
this.proxyHost = this.configService.get<string>("PCAT_PROXY_HOST", DI_HOST);
this.proxyUser = this.configService.get<string>(
"PCAT_PROXY_USER",
DI_DEFAULT_USER,
);
this.proxyPass = this.configService.get<string>(
"PCAT_PROXY_PASS",
DI_DEFAULT_PASS,
);
}
async onModuleInit(): Promise<void> {
try {
await this.launchBrowser();
this.logger.log("Browser launched for JWT capture");
} catch (err) {
this.logger.error(
`Failed to launch browser on init: ${(err as Error).message}`,
);
}
// Start business hours scheduling
if (this.isBusinessHours()) {
this.captureToPool().catch((err) => {
this.logger.error(`Initial pool capture failed: ${(err as Error).message}`);
});
}
this.scheduleBusinessHours();
}
async onModuleDestroy(): Promise<void> {
this.clearAllRefreshTimers();
if (this.businessHoursTimer) {
clearTimeout(this.businessHoursTimer);
this.businessHoursTimer = null;
}
await this.closeBrowser();
this.logger.log("Browser closed on module destroy");
}
// ─── Public API ───────────────────────────────────────────
/**
* Acquire a matched JWT + proxy session from the pool.
* Returns a valid slot (round-robin) or captures on-demand if pool is empty.
*/
async acquireSession(): Promise<PcatSession> {
this.trackRequest();
// Try to find a valid slot in the pool
const slot = this.pickValidSlot();
if (slot) {
// Check if we should scale up in the background
this.maybeScaleUp();
return this.slotToSession(slot);
}
// No valid slot — capture on-demand
this.logger.log("JWT pool empty — capturing on-demand...");
const newSlot = await this.captureToPool();
return this.slotToSession(newSlot);
}
/**
* Invalidate a session after 401/403, remove the slot, and capture a replacement.
*/
async invalidateSession(session: PcatSession): Promise<void> {
const idx = this.pool.indexOf(session._slot);
if (idx !== -1) {
this.clearSlotTimer(this.pool[idx]);
this.pool.splice(idx, 1);
this.logger.log(
`JWT pool: slot invalidated (port ${session._slot.proxyPort}), ${this.pool.length} remaining`,
);
}
// Capture replacement in background (don't block the caller's retry)
if (this.isBusinessHours() || this.pool.length === 0) {
this.captureToPool().catch((err) => {
this.logger.error(`Replacement capture failed: ${(err as Error).message}`);
});
}
}
// ─── Pool management ──────────────────────────────────────
private pickValidSlot(): JwtSlot | null {
const now = Math.floor(Date.now() / 1000);
// Remove expired slots
this.pool = this.pool.filter((s) => {
if (s.jwt.exp - now < 30) {
this.clearSlotTimer(s);
return false;
}
return true;
});
if (this.pool.length === 0) return null;
// Round-robin across valid slots
this.requestRoundRobin = this.requestRoundRobin % this.pool.length;
const slot = this.pool[this.requestRoundRobin];
this.requestRoundRobin++;
return slot;
}
private slotToSession(slot: JwtSlot): PcatSession {
const proxyUrl = this.useProxy
? `http://${this.proxyUser}:${this.proxyPass}@${this.proxyHost}:${slot.proxyPort}`
: null;
const proxyConfig = this.useProxy
? {
server: `http://${this.proxyHost}:${slot.proxyPort}`,
username: this.proxyUser,
password: this.proxyPass,
}
: null;
return {
authorization: slot.jwt.raw,
proxyUrl,
proxyConfig,
_slot: slot,
};
}
// ─── Core capture + pool add ──────────────────────────────
private async captureToPool(): Promise<JwtSlot> {
await this.semaphore.acquire();
try {
await this.ensureBrowser();
const maxRetries = 4;
for (let attempt = 0; attempt < maxRetries; attempt++) {
const port = this.allocatePort();
const siteUrl = this.getAvailableSite();
const jwt = await this.attemptCapture(siteUrl, port);
if (jwt) {
const slot: JwtSlot = {
jwt,
proxyPort: port,
siteUsed: siteUrl,
capturedAt: Date.now(),
refreshTimer: null,
};
// Mark site as used
this.siteLastUsedAt.set(siteUrl, Date.now());
// Add to pool
this.pool.push(slot);
// Schedule refresh if in business hours
if (this.isBusinessHours()) {
this.scheduleSlotRefresh(slot);
}
const ttl = jwt.exp - Math.floor(Date.now() / 1000);
const refreshIn = this.isBusinessHours()
? Math.max(ttl - REFRESH_BUFFER, 30)
: null;
this.logger.log(
`JWT pool: slot captured, TTL: ${ttl}s${refreshIn ? `, refresh in ${refreshIn}s` : ""}, pool size: ${this.pool.length}`,
);
return slot;
}
this.logger.warn(
`JWT capture attempt ${attempt + 1}/${maxRetries} failed (${new URL(siteUrl).hostname}), trying next site...`,
);
}
throw new Error("Failed to capture JWT after all retries");
} finally {
this.semaphore.release();
}
}
// ─── Timer-based refresh per slot ─────────────────────────
private scheduleSlotRefresh(slot: JwtSlot): void {
this.clearSlotTimer(slot);
const now = Math.floor(Date.now() / 1000);
const delayMs = Math.max((slot.jwt.exp - now - REFRESH_BUFFER) * 1000, 30_000);
slot.refreshTimer = setTimeout(async () => {
// Don't refresh outside business hours
if (!this.isBusinessHours()) {
this.logger.debug("JWT pool: refresh timer fired outside business hours, skipping");
return;
}
try {
const newSlot = await this.captureToPool();
// Remove old slot
const idx = this.pool.indexOf(slot);
if (idx !== -1) {
this.pool.splice(idx, 1);
}
this.logger.log(
`JWT pool: slot refreshed, TTL: ${newSlot.jwt.exp - Math.floor(Date.now() / 1000)}s`,
);
} catch (err) {
this.logger.error(`JWT pool: refresh failed: ${(err as Error).message}`);
// Schedule a retry in 30s if still in business hours
if (this.isBusinessHours()) {
slot.refreshTimer = setTimeout(() => {
this.scheduleSlotRefresh(slot);
}, 30_000);
}
}
}, delayMs);
}
private clearSlotTimer(slot: JwtSlot): void {
if (slot.refreshTimer) {
clearTimeout(slot.refreshTimer);
slot.refreshTimer = null;
}
}
private clearAllRefreshTimers(): void {
for (const slot of this.pool) {
this.clearSlotTimer(slot);
}
this.logger.log("JWT pool: all refresh timers cleared");
}
// ─── Business hours scheduling ────────────────────────────
/**
* Get current Istanbul hour and minute using Intl.DateTimeFormat.
* This works correctly regardless of the server's local timezone.
*/
private getIstanbulTime(): { hour: number; minute: number } {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Istanbul",
hour: "numeric",
minute: "numeric",
hour12: false,
}).formatToParts(new Date());
const hour = parseInt(parts.find((p) => p.type === "hour")!.value, 10);
const minute = parseInt(parts.find((p) => p.type === "minute")!.value, 10);
return { hour, minute };
}
private isBusinessHours(): boolean {
const { hour } = this.getIstanbulTime();
return hour >= 9 && hour < 19;
}
private scheduleBusinessHours(): void {
if (this.businessHoursTimer) {
clearTimeout(this.businessHoursTimer);
this.businessHoursTimer = null;
}
const { hour, minute } = this.getIstanbulTime();
let delayMs: number;
let nextEvent: string;
if (this.isBusinessHours()) {
// Schedule 19:00 stop
const minsUntil19 = (19 - hour - 1) * 60 + (60 - minute);
delayMs = minsUntil19 * 60 * 1000;
nextEvent = "stop (19:00)";
this.businessHoursTimer = setTimeout(() => {
this.logger.log("JWT pool: business hours ended, timers cleared");
this.clearAllRefreshTimers();
this.scheduleBusinessHours(); // schedule next 09:00 start
}, delayMs);
} else {
// Schedule 09:00 start
let minsUntil9: number;
if (hour >= 19) {
// Same day evening → next day 09:00
minsUntil9 = (24 - hour + 9 - 1) * 60 + (60 - minute);
} else {
// Before 09:00
minsUntil9 = (9 - hour - 1) * 60 + (60 - minute);
}
delayMs = minsUntil9 * 60 * 1000;
nextEvent = "start (09:00)";
this.businessHoursTimer = setTimeout(() => {
this.logger.log("JWT pool: business hours started, capturing initial slot");
this.captureToPool().catch((err) => {
this.logger.error(`Business hours initial capture failed: ${(err as Error).message}`);
});
this.scheduleBusinessHours(); // schedule 19:00 stop
}, delayMs);
}
this.logger.log(
`JWT pool: next ${nextEvent} in ${Math.round(delayMs / 60_000)}min (Istanbul: ${hour}:${String(minute).padStart(2, "0")})`,
);
}
// ─── RPM tracking & dynamic scaling ───────────────────────
private trackRequest(): void {
const now = Date.now();
this.requestTimestamps.push(now);
// Prune old timestamps outside the window
const cutoff = now - RPM_WINDOW;
while (this.requestTimestamps.length > 0 && this.requestTimestamps[0] < cutoff) {
this.requestTimestamps.shift();
}
}
private getRPM(): number {
const now = Date.now();
const cutoff = now - RPM_WINDOW;
return this.requestTimestamps.filter((t) => t >= cutoff).length;
}
private getDesiredPoolSize(): number {
const rpm = this.getRPM();
return Math.min(Math.max(1, Math.ceil(rpm / RPM_PER_SLOT)), MAX_POOL_SIZE);
}
private maybeScaleUp(): void {
if (!this.isBusinessHours()) return;
const desired = this.getDesiredPoolSize();
if (this.pool.length < desired) {
this.logger.log(
`JWT pool: scaling up, ${this.pool.length}/${desired} slots (RPM: ${this.getRPM()})`,
);
this.captureToPool().catch((err) => {
this.logger.warn(`JWT pool: scale-up capture failed: ${(err as Error).message}`);
});
}
}
// ─── Site cooldown & port allocation ──────────────────────
private getAvailableSite(): string {
const now = Date.now();
// Try round-robin, preferring sites not on cooldown
for (let i = 0; i < JWT_SITES.length; i++) {
const idx = (this.siteIndex + i) % JWT_SITES.length;
const site = JWT_SITES[idx];
const lastUsed = this.siteLastUsedAt.get(site) || 0;
if (now - lastUsed >= SITE_COOLDOWN) {
this.siteIndex = (idx + 1) % JWT_SITES.length;
return site;
}
}
// All on cooldown — pick the one with oldest usage
let oldestIdx = 0;
let oldestTime = Infinity;
for (let i = 0; i < JWT_SITES.length; i++) {
const lastUsed = this.siteLastUsedAt.get(JWT_SITES[i]) || 0;
if (lastUsed < oldestTime) {
oldestTime = lastUsed;
oldestIdx = i;
}
}
this.siteIndex = (oldestIdx + 1) % JWT_SITES.length;
return JWT_SITES[oldestIdx];
}
private allocatePort(): number {
return DI_PORT_MIN + Math.floor(Math.random() * (DI_PORT_MAX - DI_PORT_MIN + 1));
}
// ─── JWT capture via Playwright ───────────────────────────
private async attemptCapture(
siteUrl: string,
port: number,
): Promise<PcatJwtToken | null> {
let context: BrowserContext | null = null;
const startTime = Date.now();
try {
// Build context options with proxy
const contextOptions: Record<string, unknown> = {};
if (this.useProxy) {
contextOptions.proxy = {
server: `http://${this.proxyHost}:${port}`,
username: this.proxyUser,
password: this.proxyPass,
};
}
context = await this.browser!.newContext(contextOptions);
const page = await context.newPage();
// Intercept requests to parts-catalogs.com
let capturedJwt: string | null = null;
page.on("request", (request) => {
if (capturedJwt) return;
const url = request.url();
if (
url.includes("parts-catalogs.com") ||
url.includes("api.parts-catalogs.com")
) {
const auth = request.headers()["authorization"];
if (auth) {
capturedJwt = auth;
this.logger.debug("JWT intercepted from request");
}
}
});
// Block heavy resources to save proxy bandwidth
await page.route("**/*", (route) => {
const url = route.request().url();
const type = route.request().resourceType();
// Block images, fonts, media
if (["image", "font", "media"].includes(type)) {
return route.abort();
}
// Block known trackers/analytics
if (
url.includes("google-analytics.com") ||
url.includes("googletagmanager.com") ||
url.includes("mc.yandex.ru") ||
url.includes("facebook.net") ||
url.includes("doubleclick.net") ||
url.includes("hotjar.com")
) {
return route.abort();
}
return route.continue();
});
// Navigate — networkidle waits for widget JS to load + make API calls
try {
await page.goto(siteUrl, {
timeout: PAGE_TIMEOUT,
waitUntil: "networkidle",
});
} catch (navErr) {
// Navigation may timeout but JWT could still be captured
this.logger.debug(
`Navigation ended: ${(navErr as Error).message?.slice(0, 80)}`,
);
}
// Poll for JWT
for (let i = 0; i < CAPTURE_POLL_MAX; i++) {
if (capturedJwt) break;
await new Promise((r) => setTimeout(r, CAPTURE_POLL_INTERVAL));
}
const elapsed = Date.now() - startTime;
if (capturedJwt) {
const token = this.parseJwt(capturedJwt);
this.logger.log(
`JWT captured in ${elapsed}ms from ${new URL(siteUrl).hostname}`,
);
return token;
}
this.logger.debug(`No JWT after ${elapsed}ms from ${siteUrl}`);
return null;
} catch (err) {
this.logger.warn(`JWT capture error: ${(err as Error).message}`);
return null;
} finally {
if (context) {
try {
await Promise.race([
context.close(),
new Promise((r) => setTimeout(r, CONTEXT_CLOSE_TIMEOUT)),
]);
} catch {
// ignore
}
}
}
}
private parseJwt(rawToken: string): PcatJwtToken {
const parts = rawToken.split(".");
if (parts.length !== 3) {
throw new Error("Invalid JWT format");
}
// Decode payload with proper base64url padding
let payloadB64 = parts[1];
const padding = 4 - (payloadB64.length % 4);
if (padding !== 4) {
payloadB64 += "=".repeat(padding);
}
const payload = JSON.parse(
Buffer.from(payloadB64, "base64url").toString("utf-8"),
);
return {
raw: rawToken,
exp: payload.exp || 0,
host: payload.host || "",
apiKey: payload.apiKey || "",
apiPath: payload.apiPath || "",
ip: payload.ip || "",
hash: payload.h || "",
};
}
// ─── Browser lifecycle ───────────────────────────────────
private async launchBrowser(): Promise<void> {
if (this.launching) {
return this.launching;
}
this.launching = this._doLaunch();
try {
await this.launching;
} finally {
this.launching = null;
}
}
private async _doLaunch(): Promise<void> {
const { chromium } = await import("playwright");
this.browser = await chromium.launch({
headless: true,
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-accelerated-2d-canvas",
"--disable-gpu",
],
});
this.browser.on("disconnected", () => {
this.logger.warn("Browser disconnected — will relaunch on next request");
this.browser = null;
});
}
private async closeBrowser(): Promise<void> {
if (this.browser) {
try {
await this.browser.close();
} catch {
// ignore
}
this.browser = null;
}
}
private async ensureBrowser(): Promise<void> {
if (this.browser?.isConnected()) return;
this.logger.log("Browser not connected — relaunching");
await this.launchBrowser();
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { PartsCatalogsAuthService } from "./parts-catalogs-auth.service";
import { PartsCatalogsService } from "./parts-catalogs.service";
@Module({
providers: [PartsCatalogsAuthService, PartsCatalogsService],
exports: [PartsCatalogsService],
})
export class PartsCatalogsModule {}

View File

@@ -0,0 +1,240 @@
/**
* Parts-Catalogs API Service — HTTP client for parts-catalogs.com
*
* All requests go through the same DataImpulse proxy as the JWT capture
* to ensure the JWT's IP-bound constraint is satisfied.
*/
import { Injectable, Logger } from "@nestjs/common";
import { PartsCatalogsAuthService } from "./parts-catalogs-auth.service";
import { RedisService } from "../../redis/redis.service";
import type {
PcatVinResult,
PcatCar,
PcatGroup,
PcatPartsResult,
PcatSession,
} from "./parts-catalogs.types";
const API_BASE = "https://api.parts-catalogs.com/v1";
const REQUEST_TIMEOUT = 30_000;
@Injectable()
export class PartsCatalogsService {
private readonly logger = new Logger(PartsCatalogsService.name);
constructor(
private authService: PartsCatalogsAuthService,
private redis: RedisService,
) {}
/** Mark parts-catalogs as actively used (5min TTL) to defer prefetch worker */
private async touchActivity(): Promise<void> {
try {
await this.redis.set("prefetch:activity:parts-catalogs", String(Date.now()), 300);
} catch {
// Non-critical
}
}
/**
* VIN decode — returns one or more car matches.
*/
async decodeVin(vin: string): Promise<PcatVinResult | null> {
try {
const data = await this.fetchWithAuth("/car/info", { q: vin });
if (!data || typeof data !== "object") {
return null;
}
// Response can be a single car or array of cars depending on VIN
const rawCars = Array.isArray(data) ? data : [data];
const cars: PcatCar[] = [];
for (const item of rawCars) {
if (!item) continue;
// Each car result may have nested catalog info
const catalogId = item.catalogId || item.catalog?.id || "";
const carId = item.id || item.carId || "";
if (!carId) continue;
cars.push({
id: String(carId),
name: item.name || item.title || "",
description: item.description || item.modelName || undefined,
parameters: Array.isArray(item.parameters) ? item.parameters : undefined,
catalogId: String(catalogId),
});
}
if (cars.length === 0) return null;
return { cars };
} catch (err) {
this.logger.warn(`VIN decode failed for ${vin}: ${(err as Error).message}`);
return null;
}
}
/**
* Get category groups for a car.
* Pass groupId for subgroups.
*/
async fetchGroups(
catalogId: string,
carId: string,
groupId?: string,
carParams?: Record<string, string>,
): Promise<PcatGroup[]> {
await this.touchActivity();
const params: Record<string, string> = { carId };
if (groupId) params.groupId = groupId;
if (carParams) Object.assign(params, carParams);
const data = await this.fetchWithAuth(
`/catalogs/${catalogId}/groups2/`,
params,
);
if (!Array.isArray(data)) return [];
return data.map((g: any) => ({
id: String(g.id),
parentId: g.parentId ? String(g.parentId) : undefined,
name: g.name || "",
img: g.img || undefined,
hasSubgroups: !!g.hasSubgroups,
hasParts: !!g.hasParts,
}));
}
/**
* Get parts + schema image + hotspot positions for a group.
*/
async fetchParts(
catalogId: string,
carId: string,
groupId: string,
carParams?: Record<string, string>,
): Promise<PcatPartsResult | null> {
await this.touchActivity();
const params: Record<string, string> = { carId, groupId };
if (carParams) Object.assign(params, carParams);
const data = await this.fetchWithAuth(
`/catalogs/${catalogId}/parts2`,
params,
);
if (!data || typeof data !== "object") return null;
return {
img: data.img || "",
imgDescription: data.imgDescription || undefined,
partGroups: Array.isArray(data.partGroups)
? data.partGroups.map((pg: any) => ({
name: pg.name || undefined,
number: pg.number || undefined,
positionNumber: pg.positionNumber || undefined,
parts: Array.isArray(pg.parts)
? pg.parts.map((p: any) => ({
id: p.id ? String(p.id) : undefined,
number: p.number || "",
name: p.name || "",
nameId: p.nameId || undefined,
notice: p.notice || undefined,
positionNumber: p.positionNumber || undefined,
}))
: [],
}))
: [],
positions: Array.isArray(data.positions)
? data.positions.map((pos: any) => ({
number: String(pos.number),
coordinates: pos.coordinates || [0, 0, 0, 0],
}))
: [],
};
}
/**
* Check if a VIN is potentially supported.
* Parts-catalogs.com covers most brands, so this is broadly true.
*/
isSupported(_vin: string): boolean {
return true;
}
// ─── Private ─────────────────────────────────────────────
private async fetchWithAuth(
endpoint: string,
params?: Record<string, string>,
): Promise<any> {
const maxRetries = 2;
let session: PcatSession | null = null;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
session = await this.authService.acquireSession();
const url = new URL(`${API_BASE}${endpoint}`);
if (params) {
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
}
try {
const fetchOptions: RequestInit & { dispatcher?: any } = {
method: "GET",
headers: {
Authorization: session.authorization,
Accept: "application/json",
"User-Agent":
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT),
};
// Use undici ProxyAgent if proxy is configured
if (session.proxyUrl) {
const { ProxyAgent } = await import("undici");
fetchOptions.dispatcher = new ProxyAgent(session.proxyUrl);
}
const response = await fetch(url.toString(), fetchOptions);
if (response.ok) {
return await response.json();
}
if (response.status === 401 || response.status === 403) {
this.logger.warn(
`Auth error (${response.status}) on ${endpoint}, attempt ${attempt + 1}/${maxRetries + 1}`,
);
if (attempt < maxRetries) {
await this.authService.invalidateSession(session);
continue;
}
}
const text = await response.text().catch(() => "");
throw new Error(
`HTTP ${response.status} from ${endpoint}: ${text.slice(0, 200)}`,
);
} catch (err) {
if ((err as Error).name === "TimeoutError") {
this.logger.warn(`Timeout on ${endpoint}, attempt ${attempt + 1}`);
if (attempt < maxRetries) continue;
}
throw err;
}
}
throw new Error(`Max retries exceeded for ${endpoint}`);
}
}

View File

@@ -0,0 +1,79 @@
export interface PcatJwtToken {
raw: string;
exp: number;
host: string;
apiKey: string;
apiPath: string;
ip: string;
hash: string;
}
export interface JwtSlot {
jwt: PcatJwtToken;
proxyPort: number;
siteUsed: string;
capturedAt: number;
refreshTimer: ReturnType<typeof setTimeout> | null;
}
export interface PcatSession {
authorization: string;
proxyUrl: string | null;
proxyConfig: { server: string; username: string; password: string } | null;
_slot: JwtSlot;
}
export interface PcatCarParameter {
key: string;
idx: string;
value: string;
}
export interface PcatCar {
id: string;
name: string;
description?: string;
parameters?: PcatCarParameter[];
catalogId: string;
}
export interface PcatVinResult {
cars: PcatCar[];
}
export interface PcatGroup {
id: string;
parentId?: string;
name: string;
img?: string;
hasSubgroups: boolean;
hasParts: boolean;
}
export interface PcatPart {
id?: string;
number: string; // OEM code
name: string;
nameId?: number;
notice?: string;
positionNumber?: string;
}
export interface PcatPartGroup {
name?: string;
number?: string;
positionNumber?: string;
parts: PcatPart[];
}
export interface PcatPosition {
number: string;
coordinates: [number, number, number, number]; // x, y, w, h
}
export interface PcatPartsResult {
img: string;
imgDescription?: string;
partGroups: PcatPartGroup[];
positions: PcatPosition[];
}

View File

@@ -261,6 +261,7 @@ export class PL24Service {
return this.fordLegacyService.fetchPartsByPath(linkPath, serviceName);
}
await this.touchActivity();
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);
@@ -384,6 +385,7 @@ export class PL24Service {
}
this.logger.log(`Fetching sub-groups by path: ${linkPath}`);
await this.touchActivity();
try {
await this.authService.authorizeService(serviceName);
@@ -411,6 +413,7 @@ export class PL24Service {
serviceName: string,
mainGroupsPath: string,
): Promise<PL24DecodedCategory[]> {
await this.touchActivity();
try {
await this.authService.authorizeService(serviceName);
const headers = await this.authService.buildAuthHeaders(serviceName);
@@ -435,6 +438,7 @@ export class PL24Service {
hotspots: PL24Hotspot[];
} | null> {
if (!imageUrl) return null;
await this.touchActivity();
// Extract image ID for dedup
const imageId = this.extractImageIdFromUrl(imageUrl);
@@ -572,6 +576,15 @@ export class PL24Service {
return SERVICE_TO_BRAND[serviceName] || null;
}
/** Mark PL24 as actively used (5min TTL) to defer prefetch worker */
private async touchActivity(): Promise<void> {
try {
await this.redis.set("prefetch:activity:pl24", String(Date.now()), 300);
} catch {
// Non-critical — don't break the request
}
}
// ==================== PRIVATE: Request helpers ====================
/**