/** * 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("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 { const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}vehicle:${vin}`; const cached = await this.redis.getJson(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 { 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 { 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 { const seen = new Set(); 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 { 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(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; 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 (labelvalue rows). * Captions are Turkish (lang=tr); English fallbacks kept for robustness. */ private parseFiIdentification(html: string, vin: string) { const rows: Record = {}; for (const m of html.matchAll( /([^<]*)<\/td>\s*]*>([\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(/[^<]*?-\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 !== " " ? 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(/"/g, '"').replace(/&/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 & 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(/�?34;/g, '"') .replace(/"/g, '"') .replace(/�?39;/g, "'") .replace(/'/g, "'") .replace(/ /g, " ") .replace(/&/g, "&"); } }