Files
sase.tr/apps/api/src/integrations/pl24/pl24-psa.service.ts
semih e50a907931
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
refactor(pl24): oturum modeli — tek login, çerez tabanlı authorize yenileme
Faz 1 / adım 1 (analiz: /home/s/ss/plv2.md §4.1, bulgular auth-02/03/05/06/09/11/15,
p4legacy-07, consumers-04).

SORUN: PL24'ün base JWT'si 600 saniye yaşıyor ve kod bunu "oturum" sayıyordu
(isTokenValid → login). Talep varsa her ~9 dakikada bir squeezeOut:true ile
YENİDEN LOGIN atılıyordu (günde ~144 login/hesap). PL24 hesap başına tek oturum
veriyor; bu desen hem kendi oturumumuzu sürekli düşürüyor hem de iki hesap
banından önceki otomasyon imzasını oluşturuyordu. Portalın kendisi bir kez login
olup PL24TOKEN çerezini saklıyor ve yalnız authorize'ı yeniliyor.

DEĞİŞİKLİK:
- Oturum = PL24TOKEN çerezi (süresiz). `ensureSession()` sahibi; bellekte tutulur,
  Redis'te `pl24:auth:session:<hesap>` ile 24 saat paylaşılır (api/worker aynı
  oturumu devralır). Süre kontrolü YOK — oturum yalnız sunucu reddederse düşer.
- Servis token'ı = authorize ile mint edilen 600 s'lik JWT; yalnız `Cookie:
  PL24TOKEN` ile istenir (Bearer gerekmiyor — canlı doğrulandı).
- `session_status:"gone"` ve authorize 401/403 artık okunuyor: oturum düşürülür,
  tek sefer yeniden login + retry.
- Single-flight: süreç içi in-flight promise + Redis kilidi
  (`pl24:auth:login-lock:<hesap>`), kilidi alamayan paylaşılan oturumu bekler.
  Eski prewarm iki paralel login atıyordu ve biri her boot'ta HTTP 500 alıyordu.
- Login sözleşmesi: portal ucu `/auth/ext/api/1.1/login`
  ({account,user,password,squeezeOut} → {loginStatus,sessionToken} + Set-Cookie);
  RFC7807 problem gövdesi tiplendi. Önce squeezeOut:false denenir, yalnız
  "session-limit-exceeded"/USER_ALREADY_LOGGED_IN'de squeezeOut:true ile tekrar.
  `PL24_LOGIN_API=legacy` eski uca döner (bir sürüm geri çekilme bayrağı).
- Hata sınıflandırma: account-not-active / user-not-active / authentication /
  2FA-required → ilk hatada 6 saatlik devre kesici (eskiden ölü hesap 5 dakikada
  bir sonsuza dek deneniyordu: prod'da 376 ardışık 401). Ağ/timeout → 5 dakika.
- Saatlik login tavanı (hesap başına 6) — sağlıklı günde 0-1 login beklenir.
- P4 (.action) sayfaları için authorize kaldırıldı: yalnız çerez gönderiliyor
  (canlı doğrulandı). Ford/PSA'daki 18 "ısıtma" authorize çağrısı ensureSession'a
  indirildi; demo-sayfa/401 çaresi artık servis token temizleme değil oturum
  yenileme.
- Gerçek Chrome User-Agent sabiti (eski değer hiçbir tarayıcıda yok).
- authorize'a `pl24-wmidata` eklendi (portal WMI decode'u için ön hazırlık).
- Ölü kod silindi: getAccessToken*/getSessionCookie* base-JWT yolu, hasService,
  getAvailableServices, AUTH_TOKEN_TTL.

Test: pl24-auth.service.spec.ts yeniden yazıldı (19 test: tek login, single-flight,
Redis devralma, cookie-only authorize, gone/401 yeniden login, 412 squeezeOut,
P4 cookie-only header, kalıcı hata kesicisi, saatlik tavan). Tüm PL24 + tüketici
paketleri: 188 test geçti. tsc + biome temiz.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 00:59:36 +03:00

733 lines
27 KiB
TypeScript

/**
* PL24 PSA (Peugeot / Citroën / DS) VIN-indexed decode service.
*
* Why this is separate from PL24FordLegacyService:
* PSA catalogs identify a VIN through PL24's "FI" (Fahrzeug-Identifikation) flow,
* which is fundamentally different from the family/salesType catalog browsing the
* other P4-legacy brands use. The correct flow (reverse-engineered from live PL24
* responses) is:
*
* 1. pl24-entry.action → JSESSIONID + mode + upds (initPsaSession)
* 2. vin.action?vin=… → 302 with hintstoken + redirect to
* vin-group.action?…&vin=…&hintstoken=…&openVinDialog=true (the FI page)
* 3. FI page contains:
* - the identification table (Model / MODEL YILI / AKTARMA SİSTEMLERİ /
* MOTOR / GÖVDE TİPİ / DAM …) → real model, year, transmission
* - scope rows → json-vin-main-groups.action?scope=_FCT0001…_FCT0500&vin=…
* 4. drill: json-vin-main-groups → json-vin-illustrations → vin-image-board
* (each carries vin+scope+mainGroup+mode+upds; works in any fresh session).
*
* The previous implementation (in pl24-ford-legacy.service.ts) called
* vin-group.action?vin=… directly — which returns the FULL unfiltered family list,
* so it fell back to families[0]/salesType[0] (a MANUAL base variant). That is why
* every PSA vehicle decoded as "Peugeot {VIN}", empty transmission, wrong year, and
* a manual-only parts tree (automatic gearbox parts missing). This service consumes
* the FI result instead, so the parts tree matches the actual VIN.
*
* Self-contained on purpose: it does not import PL24FordLegacyService, so changes
* here cannot affect Ford/Volvo/Nissan/Opel/Hyundai-Kia/Fiat.
*/
import { createHash } from "node:crypto";
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { extractModelYear } from "@sase/shared";
import { RedisService } from "../../redis/redis.service";
import { PL24AuthService } from "./pl24-auth.service";
import { PL24_DEFAULTS } from "./pl24.constants";
import {
type PL24DecodedCategory,
type PL24DecodedVehicle,
type PL24MainGroup,
type PL24Part,
type PL24PartsResponse,
SERVICE_TO_BRAND,
} from "./pl24.types";
interface PsaSession {
jsessionId: string;
mode: string;
upds: string;
}
@Injectable()
export class PL24PsaService {
private readonly logger = new Logger(PL24PsaService.name);
private readonly baseUrl: string;
private readonly timeout = 30000;
private readonly language = "tr";
constructor(
private readonly authService: PL24AuthService,
private readonly configService: ConfigService,
private readonly redis: RedisService,
) {
this.baseUrl = this.configService.get<string>("pl24.baseUrl", "https://www.partslink24.com");
}
// ==================== PUBLIC: VIN decode ====================
/**
* Decode a PSA VIN via the FI flow and return the vehicle with its scope categories.
* Signature mirrors PL24FordLegacyService.decodeVinForService so the orchestrator
* can route LEGACY_PSA services here with a one-line change.
*/
async decodeVinForService(
vin: string,
serviceName: string,
_userId?: string,
): Promise<PL24DecodedVehicle | null> {
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}vehicle:${vin}`;
const cached = await this.redis.getJson<PL24DecodedVehicle>(cacheKey);
if (cached) return cached;
try {
const fi = await this.runFi(serviceName, vin);
if (!fi) return null;
const vehicle: PL24DecodedVehicle = {
brand: SERVICE_TO_BRAND[serviceName] || serviceName.replace(/_parts$/, ""),
model: fi.ident.model || SERVICE_TO_BRAND[serviceName] || "",
year: fi.ident.year ?? extractModelYear(vin) ?? 0,
series: null,
bodyType: fi.ident.bodyType,
engineCode: fi.ident.engineCode,
engineType: fi.ident.engine,
engineVolume: null,
transmission: fi.ident.transmission,
driveType: null,
colorCode: null,
productionDate: null,
raw: { fiResolved: true, dam: fi.ident.dam, modelYearRaw: fi.ident.yearRaw },
catalogInfo: {
serviceName,
vehicleId: vin,
catalogPath: `/psa/${serviceName}`,
baseUrl: this.baseUrl,
psaMode: fi.session.mode,
psaUpds: fi.session.upds,
},
categories: fi.scopes,
};
await this.redis.setJson(cacheKey, vehicle, 86400);
this.logger.log(
`PSA FI: decoded VIN ${vin} (${serviceName}) — ${vehicle.brand} ${vehicle.model} ` +
`${vehicle.year} [${vehicle.transmission ?? "?"}], ${fi.scopes.length} scopes`,
);
return vehicle;
} catch (error) {
this.logger.error(`PSA FI decode error (${serviceName}): ${(error as Error).message}`);
return null;
}
}
/**
* Re-fetch only the scope categories for an already-decoded PSA VIN.
* Called by CategoriesService when a PSA VIN-decoded vehicle has no stored categories.
*/
async fetchCategoriesForPsaVin(serviceName: string, vin: string): Promise<PL24DecodedCategory[]> {
const fi = await this.runFi(serviceName, vin);
if (!fi) return [];
this.logger.log(`PSA FI: ${fi.scopes.length} scopes for ${serviceName} VIN ${vin}`);
return fi.scopes;
}
// ==================== PUBLIC: lazy drill (orchestrator dispatch targets) ====================
/**
* Scope → main groups. linkPath: /psa/{svc}/json-vin-main-groups.action?scope=…&vin=…
*/
async fetchVinMainGroups(linkPath: string): Promise<PL24MainGroup[]> {
return this.drillJsonLevel(linkPath, "maingroups", (item, svc) => {
if (item.subheader || !item.jsonUrl || !item.path) return null;
return {
id: item.path,
code: item.path,
name: item.name,
linkPath: this.absPath(svc, item.jsonUrl),
};
});
}
/**
* Main group → illustrations. linkPath: /psa/{svc}/json-vin-illustrations.action?mainGroup=…&vin=…
*/
async fetchVinIllustrations(linkPath: string): Promise<PL24MainGroup[]> {
const seen = new Set<string>();
return this.drillJsonLevel(linkPath, "illus", (item, svc) => {
if (item.subheader || !item.url) return null;
const key = item.path || item.illCodeTec || item.name;
if (seen.has(key)) return null;
seen.add(key);
return {
id: item.illCodeTec || item.path || item.name,
code: item.illCodeTec || item.path || item.name,
name: item.name,
linkPath: this.absPath(svc, item.url),
};
});
}
/**
* Illustration → parts + schema image. linkPath: /psa/{svc}/vin-image-board.action?illCode=…&vin=…
* Same BOM/image pipeline as the family/salesType PSA flow (PL24 reuses image-board.action).
*/
async fetchVinParts(
linkPath: string,
serviceName: string,
bypassCache = false,
): Promise<PL24PartsResponse> {
const svc = this.svcFromPath(linkPath) || serviceName;
const pathHash = createHash("sha256").update(linkPath).digest("hex").substring(0, 16);
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}psa:vinparts:${pathHash}`;
if (!bypassCache) {
const hit = await this.redis.getJson<PL24PartsResponse>(cacheKey);
if (hit) return hit;
}
const session = await this.initPsaSession(svc);
if (!session) return { success: false, groupId: pathHash, groupName: "", parts: [] };
const url = `${this.baseUrl}${this.refreshSessionParams(linkPath, session)}`;
const html = await this.fetchPsaPage(url, svc, session.jsessionId);
if (!html) return { success: false, groupId: pathHash, groupName: "", parts: [] };
const groupName = this.extractPageTitle(html);
const parts = this.parsePsaBomParts(html);
let schemaImageUrl: string | undefined;
let schemaImageBuffer: Buffer | undefined;
let schemaImageContentType: string | undefined;
let schemaWidth: number | undefined;
let schemaHeight: number | undefined;
let schemaHotspots: PL24PartsResponse["hotspots"];
const ticketUrl = this.extractPsaImageTicketUrl(html);
if (ticketUrl) {
try {
const ticketRaw = await this.fetchPsaPage(
`${this.baseUrl}${ticketUrl}`,
svc,
session.jsessionId,
true,
);
if (ticketRaw) {
const ticketData = JSON.parse(ticketRaw) as {
pathparams?: { ticket?: string; url?: string };
};
if (ticketData.pathparams?.url) {
const ticket = ticketData.pathparams.ticket || "";
const imgPath = ticketData.pathparams.url;
const fullImgUrl = ticket
? `${this.baseUrl}${imgPath}${imgPath.includes("?") ? "&" : "?"}ticket=${ticket}`
: `${this.baseUrl}${imgPath}`;
schemaImageUrl = fullImgUrl;
const img = await this.fetchPsaImageData(fullImgUrl, svc, session.jsessionId);
if (img) {
schemaImageBuffer = img.buffer;
schemaImageContentType = img.contentType;
schemaWidth = img.width;
schemaHeight = img.height;
schemaHotspots = img.hotspots;
}
}
}
} catch (e) {
this.logger.warn(`PSA VIN image pipeline error: ${(e as Error).message}`);
}
}
const result: PL24PartsResponse = {
success: true,
groupId: pathHash,
groupName,
schemaImageUrl,
schemaImageBuffer,
schemaImageContentType,
schemaWidth,
schemaHeight,
hotspots: schemaHotspots,
parts,
};
if (parts.length > 0) {
const { schemaImageBuffer: _buf, ...cacheable } = result;
await this.redis.setJson(cacheKey, cacheable, 3600);
}
return result;
}
// ==================== PRIVATE: FI flow ====================
/**
* Run the FI flow: session → vin.action (hintstoken) → FI page → parse identification + scopes.
*/
private async runFi(
serviceName: string,
vin: string,
): Promise<{
session: PsaSession;
ident: ReturnType<PL24PsaService["parseFiIdentification"]>;
scopes: PL24DecodedCategory[];
} | null> {
const session = await this.initPsaSession(serviceName);
if (!session) return null;
const { jsessionId, mode, upds } = session;
// Step 1: vin.action → 302 with hintstoken in the redirect target (the FI page URL).
const vinUrl =
`${this.baseUrl}/psa/${serviceName}/vin.action` +
`?lang=${this.language}&mode=${mode}&upds=${upds}&vin=${encodeURIComponent(vin)}`;
const redirect = await this.psaRequest(vinUrl, serviceName, jsessionId, { manual: true });
if (!redirect.location) {
this.logger.warn(`PSA FI: vin.action returned no redirect for ${vin} (${serviceName})`);
return null;
}
// Step 2: follow to the FI page (vin-group.action?…&hintstoken=…&openVinDialog=true).
const fiUrl = redirect.location.startsWith("http")
? redirect.location
: `${this.baseUrl}${redirect.location}`;
const html = await this.fetchPsaPage(fiUrl, serviceName, jsessionId);
if (!html) return null;
const support = this.extractScriptVariable<{ demo?: boolean; role?: string }>(
html,
"PL24_SUPPORT",
);
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
this.logger.warn(`PSA FI: demo mode for ${serviceName}`);
return null;
}
const ident = this.parseFiIdentification(html, vin);
const scopes = this.parseVinScopes(html, serviceName);
if (scopes.length === 0) {
this.logger.warn(`PSA FI: no scopes parsed for ${vin} (${serviceName})`);
}
return { session, ident, scopes };
}
/**
* Parse the FI identification table (<td class="caption">label</td><td>value</td> rows).
* Captions are Turkish (lang=tr); English fallbacks kept for robustness.
*/
private parseFiIdentification(html: string, vin: string) {
const rows: Record<string, string> = {};
for (const m of html.matchAll(
/<td class="caption">([^<]*)<\/td>\s*<td[^>]*>([\s\S]*?)<\/td>/g,
)) {
const key = this.decodeEntities(m[1]).trim().toLocaleUpperCase("tr");
const val = this.decodeEntities(m[2].replace(/<[^>]+>/g, "")).trim();
if (key && val && !(key in rows)) rows[key] = val;
}
const get = (...labels: string[]): string | null => {
for (const l of labels) {
const v = rows[l.toLocaleUpperCase("tr")];
if (v) return v;
}
return null;
};
const model =
get("MODEL") ||
(html.match(/<title>[^<]*?-\s*([^<]+?)\s*-\s*partslink24/i)?.[1]?.trim() ?? null);
const yearRaw = get("MODEL YILI", "MODEL YEAR");
return {
model,
yearRaw,
year: this.parseFiYear(yearRaw) ?? extractModelYear(vin),
transmission: get("AKTARMA SİSTEMLERİ", "TRANSMISSION", "AKTARMA SISTEMLERI"),
engine: get("MOTOR", "ENGINE"),
engineCode: null as string | null,
bodyType: get("GÖVDE TİPİ", "SILHOUETTE", "BODY", "GOVDE TIPI"),
dam: get("DAM"),
};
}
/**
* "AM 99" → 1999, "AM 2005" → 2005, "AM 96" → 1996, null/no digits → null.
* "01 MAJÖR ENDEKS" / "BÜYÜK 07 ENDEKSİ" are PL24 *index* labels, not calendar years —
* a bare 2-digit there must NOT be read as a year; we return null so the caller falls
* back to extractModelYear(vin). A real 4-digit year is always trusted.
*/
private parseFiYear(raw: string | null): number | null {
if (!raw) return null;
const four = raw.match(/\b(19|20)\d{2}\b/);
if (four) return Number.parseInt(four[0], 10);
if (/endeks|index/i.test(raw)) return null;
const two = raw.match(/\b(\d{2})\b/);
if (two) {
const yy = Number.parseInt(two[1], 10);
const cutoff = (new Date().getFullYear() % 100) + 1;
return yy <= cutoff ? 2000 + yy : 1900 + yy;
}
return null;
}
/**
* Parse scope rows from the FI page. Each row's jsonUrl points at
* json-vin-main-groups.action?scope=_FCTxxxx&vin=… — the VIN-indexed category tree.
*/
private parseVinScopes(html: string, serviceName: string): PL24DecodedCategory[] {
const scopes: PL24DecodedCategory[] = [];
const re =
/<tr[^>]*jsonUrl="(json-vin-main-groups\.action[^"]*scope=([^&"]+)[^"]*)"[^>]*>([\s\S]*?)<\/tr>/g;
for (const m of html.matchAll(re)) {
const jsonUrl = this.decodeEntities(m[1]);
const scopeId = decodeURIComponent(m[2]);
const name =
m[3]
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim() || scopeId;
scopes.push({
code: scopeId,
nameEn: name,
nameTr: name,
description: null,
iconUrl: null,
subGroups: [],
linkPath: this.absPath(serviceName, jsonUrl),
});
}
return scopes;
}
// ==================== PRIVATE: generic JSON drill ====================
/**
* Fetch a json-vin-* level, refresh stale mode/upds against a fresh session, map items.
*/
private async drillJsonLevel(
linkPath: string,
cacheTag: string,
map: (
item: {
name: string;
path?: string;
jsonUrl?: string;
url?: string | null;
illCodeTec?: string;
subheader?: boolean;
},
svc: string,
) => PL24MainGroup | null,
): Promise<PL24MainGroup[]> {
const svc = this.svcFromPath(linkPath);
if (!svc) {
this.logger.warn(`PSA VIN drill: cannot resolve service from ${linkPath}`);
return [];
}
const pathHash = createHash("sha256").update(linkPath).digest("hex").substring(0, 16);
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}psa:vin${cacheTag}:${pathHash}`;
const cached = await this.redis.getJson<PL24MainGroup[]>(cacheKey);
if (cached) return cached;
const session = await this.initPsaSession(svc);
if (!session) return [];
const url = `${this.baseUrl}${this.refreshSessionParams(linkPath, session)}`;
const raw = await this.fetchPsaPage(url, svc, session.jsessionId, true);
if (!raw) return [];
let items: Parameters<typeof map>[0][] = [];
try {
items = (JSON.parse(raw) as { items?: typeof items }).items || [];
} catch {
return [];
}
const groups = items.map((it) => map(it, svc)).filter((g): g is PL24MainGroup => g !== null);
if (groups.length > 0) await this.redis.setJson(cacheKey, groups, 7200);
return groups;
}
// ==================== PRIVATE: session + HTTP ====================
/**
* PSA requests must leave from the same egress as their auth account: under
* PL24_TR_DISABLED the legacy "tr" auth maps to de, whose session lives behind
* the DE proxy — the auth service picks the dispatcher (null for real tr).
*/
private async psaDispatcher(): Promise<any | null> {
return this.authService.getProxyAgent4Account("tr");
}
/**
* entry.action → startup=true (302) → 302 with mode+upds. Returns JSESSIONID/mode/upds.
*/
private async initPsaSession(serviceName: string): Promise<PsaSession | null> {
// P4 PSA pages authenticate off the session cookie alone — no service token.
await this.authService.ensureSession("tr");
const headers = await this.authService.buildFordLegacyHeaders(serviceName);
const dispatcher = await this.psaDispatcher();
const withDispatcher = (o: RequestInit): RequestInit & { dispatcher?: any } =>
dispatcher ? { ...o, dispatcher } : o;
try {
const entryRes = await fetch(
`${this.baseUrl}/psa/pl24-entry.action?service=${serviceName}`,
withDispatcher({
method: "GET",
headers,
redirect: "manual",
signal: AbortSignal.timeout(this.timeout),
}),
);
const jsessionId = entryRes.headers.get("set-cookie")?.match(/JSESSIONID=([^;]+)/)?.[1] || "";
const loc1 = entryRes.headers.get("location");
if (!loc1) return null;
const hdrs2 = jsessionId
? { ...headers, Cookie: `${headers.Cookie}; JSESSIONID=${jsessionId}` }
: headers;
const startupRes = await fetch(
loc1.startsWith("http") ? loc1 : `${this.baseUrl}${loc1}`,
withDispatcher({
method: "GET",
headers: hdrs2,
redirect: "manual",
signal: AbortSignal.timeout(this.timeout),
}),
);
const loc2 = startupRes.headers.get("location") || loc1;
const mode = loc2.match(/[?&]mode=([^&]+)/)?.[1] || "";
// Keep upds URL-encoded exactly as returned (e.g. "2024.02.13+09%3A27%3A21+CET");
// re-encoding the '+' / '%3A' breaks PL24's server-side session lookup.
const upds = loc2.match(/[?&]upds=([^&]+)/)?.[1] ?? "";
if (!mode) return null;
return { jsessionId, mode, upds };
} catch (error) {
this.logger.error(`PSA session init error: ${(error as Error).message}`);
return null;
}
}
/** Low-level PSA fetch with JSESSIONID; optionally manual-redirect to read Location. */
private async psaRequest(
url: string,
serviceName: string,
jsessionId: string,
opts: { json?: boolean; manual?: boolean } = {},
): Promise<{ status: number; text: string | null; location: string | null }> {
const base = await this.authService.buildFordLegacyHeaders(serviceName);
const headers = {
...base,
Cookie: jsessionId ? `${base.Cookie}; JSESSIONID=${jsessionId}` : base.Cookie,
Accept: opts.json ? "application/json,*/*" : "text/html,application/xhtml+xml,*/*;q=0.9",
};
try {
const dispatcher = await this.psaDispatcher();
const fetchOpts: RequestInit & { dispatcher?: any } = {
method: "GET",
headers,
redirect: opts.manual ? "manual" : "follow",
signal: AbortSignal.timeout(this.timeout),
};
if (dispatcher) fetchOpts.dispatcher = dispatcher;
const res = await fetch(url, fetchOpts);
const location = res.headers.get("location");
if (opts.manual && res.status >= 300 && res.status < 400) {
return { status: res.status, text: null, location };
}
if (!res.ok) {
this.logger.warn(`PSA: HTTP ${res.status} for ${url.substring(0, 120)}`);
return { status: res.status, text: null, location };
}
return { status: res.status, text: await res.text(), location };
} catch (error) {
this.logger.error(`PSA fetch error: ${(error as Error).message}`);
return { status: 0, text: null, location: null };
}
}
private async fetchPsaPage(
url: string,
serviceName: string,
jsessionId: string,
json = false,
): Promise<string | null> {
return (await this.psaRequest(url, serviceName, jsessionId, { json })).text;
}
/** Two-step PL24 ImageViewer download (GetImageInfo → GetImage) + hotspot conversion. */
private async fetchPsaImageData(
imageUrl: string,
serviceName: string,
jsessionId: string,
): Promise<{
buffer: Buffer;
contentType: string;
width?: number;
height?: number;
hotspots?: PL24PartsResponse["hotspots"];
} | null> {
const base = await this.authService.buildFordLegacyHeaders(serviceName);
const headers: Record<string, string> = {
...base,
Cookie: jsessionId ? `${base.Cookie}; JSESSIONID=${jsessionId}` : base.Cookie,
Accept: "application/json,image/*,*/*;q=0.9",
Referer: `${this.baseUrl}/psa/${serviceName}/vin-image-board.action`,
};
const dispatcher = await this.psaDispatcher();
const withDispatcher = (o: RequestInit): RequestInit & { dispatcher?: any } =>
dispatcher ? { ...o, dispatcher } : o;
try {
const infoRes = await fetch(
`${imageUrl}&request=GetImageInfo&cv=1`,
withDispatcher({
method: "GET",
headers,
redirect: "follow",
signal: AbortSignal.timeout(this.timeout),
}),
);
if (!infoRes.ok) return null;
const info = (await infoRes.json()) as {
imageWidth: number;
imageHeight: number;
Error?: string;
hotspots?: Array<{
hsKey: string;
hsPartNo: string;
hsX: number;
hsY: number;
hsWidth: number;
hsHeight: number;
}>;
};
if (info.Error) return null;
const { imageWidth: w, imageHeight: h } = info;
const rnd = Math.floor(Math.random() * 100000);
const getImgUrl =
`${imageUrl}&request=GetImage&format=image%2Fpng` +
`&bbox=${encodeURIComponent(`0,0,${w},${h}`)}&width=${w}&height=${h}&scalefac=1.0&cv=1&rnd=${rnd}`;
const imgRes = await fetch(
getImgUrl,
withDispatcher({
method: "GET",
headers: { ...headers, Accept: "image/png,image/*,*/*;q=0.9" },
redirect: "follow",
signal: AbortSignal.timeout(this.timeout),
}),
);
if (!imgRes.ok) return null;
const contentType = imgRes.headers.get("content-type") || "image/png";
const buffer = Buffer.from(await imgRes.arrayBuffer());
const hotspots = info.hotspots?.map((hs) => ({
key: hs.hsPartNo,
areas: [
{ left: hs.hsX / w, top: hs.hsY / h, width: hs.hsWidth / w, height: hs.hsHeight / h },
],
}));
return { buffer, contentType, width: w, height: h, hotspots };
} catch (error) {
this.logger.warn(`PSA image download error: ${(error as Error).message}`);
return null;
}
}
// ==================== PRIVATE: pure parsers (self-contained) ====================
/** Parse the BOM table in *image-board.action HTML (rows carry partno="…"). */
private parsePsaBomParts(html: string): PL24Part[] {
const parts: PL24Part[] = [];
for (const segment of html.split(/<tr\s/)) {
if (!segment.includes("tc-data-row") || !segment.includes('partno="')) continue;
const rawPartno = segment.match(/\bpartno="([^"]+)"/)?.[1];
if (!rawPartno) continue;
const posno = segment.match(/\bposno="([^"]*)"/)?.[1] || "";
const hotspot = segment.match(/\bhotspot="([^"]*)"/)?.[1] || "";
const formatted = segment
.match(/class="portnoFormatted[^"]*"[^>]*>([\s\S]*?)<\/td>/)?.[1]
?.replace(/<[^>]+>/g, "")
.trim();
const oemCode = formatted || rawPartno.replace(/^0+/, "");
const name =
segment
.match(/class="partName[^"]*"[^>]*>([\s\S]*?)<\/td>/)?.[1]
?.replace(/<[^>]+>/g, "")
.trim() || "";
const remarkRaw =
segment
.match(/class="commentHtml[^"]*"[^>]*>([\s\S]*?)<\/td>/)?.[1]
?.replace(/<[^>]+>/g, "")
.trim() || "";
if (!oemCode) continue;
parts.push({
id: rawPartno,
oemCode,
name,
remark: remarkRaw && remarkRaw !== "&nbsp;" ? remarkRaw : undefined,
positionCode: posno || undefined,
hotspotId: hotspot || undefined,
unavailable: false,
});
}
return parts;
}
/** Extract the json-image-ticket.action URL from image-board jsinitparams. */
private extractPsaImageTicketUrl(html: string): string | null {
const m = html.match(/id="jsinitparams"[^>]+data-params="([^"]+)"/);
if (!m) return null;
try {
const dataParams = JSON.parse(m[1].replace(/&quot;/g, '"').replace(/&amp;/g, "&"));
return (
(dataParams.imageViewerParamsUrl as string) ||
(dataParams.jsIlluData?.[0]?.imageViewerParamsUrl as string) ||
null
);
} catch {
return null;
}
}
private extractPageTitle(html: string): string {
return (
html
.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]
?.replace(/<[^>]+>/g, "")
.trim() || ""
);
}
/** Minimal JS-variable extractor (used only for the PL24_SUPPORT demo check). */
private extractScriptVariable<T = unknown>(html: string, varName: string): T | null {
const m = html.match(
new RegExp(`(?:window\\.|var\\s+)${varName}\\s*=\\s*({[\\s\\S]*?});`, "m"),
);
if (!m?.[1]) return null;
try {
return JSON.parse(m[1]) as T;
} catch {
return null;
}
}
// ==================== PRIVATE: helpers ====================
private svcFromPath(linkPath: string): string | null {
return linkPath.match(/\/psa\/([^/]+)\//)?.[1] || null;
}
/** Prepend /psa/{svc}/ to a relative PL24 action URL, decoding &amp; entities. */
private absPath(svc: string, url: string): string {
const clean = this.decodeEntities(url);
return clean.startsWith("/") ? clean : `/psa/${svc}/${clean}`;
}
/** Replace stale mode/upds in a stored linkPath with the active session's values. */
private refreshSessionParams(linkPath: string, session: PsaSession): string {
return linkPath
.replace(/([?&]mode=)[^&]+/, `$1${session.mode}`)
.replace(/([?&]upds=)[^&]+/, `$1${session.upds}`);
}
private decodeEntities(s: string): string {
return s
.replace(/&#0?34;/g, '"')
.replace(/&quot;/g, '"')
.replace(/&#0?39;/g, "'")
.replace(/&apos;/g, "'")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&");
}
}