feat: catalog browser polish + design system refresh + translation pipeline

- catalog: P5 restriction selector flow (mainGroupsPath), grid/tree/columns view modes for brands and models with persisted user settings
- translations: bulk translateMany() path with 1d cache-miss TTL, expanded automotive dictionary; categories.service now drives EN→TR via TranslationsService instead of mapper-side strings
- pcat: migrate auth from v1 JWT to v3 widget tokens (TWS- api-key + supporting X-* headers, IP-bound via DataImpulse proxy)
- pl24: new fetchP5Restrictions() for restriction-level navigation
- subscriptions: trial extended 7 → 30 days
- design: oklch color tokens, brand semantic color, Geist + Instrument Serif fonts, tinted shadows, button "brand" variant with hover-lift, accessible focus rings, skip link, 404 NotFound page, auth layout polish
- nginx: dynamic resolver for Faro upstream
- config: OPENROUTER_API_KEY env (used by emex translate bootstrap script)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sase Dev
2026-05-09 15:54:30 +00:00
parent 14e43bc808
commit f35d64f2be
46 changed files with 2499 additions and 855 deletions

View File

@@ -1,17 +1,16 @@
/**
* Parts-Catalogs Auth Service — JWT warm pool via Playwright + DataImpulse proxy
* Parts-Catalogs Auth Service — v3 token 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.
* Tokens are captured by navigating to partner sites that embed the v3 widget.
* The widget calls /v3/api/proxy/* with `x-api-key: TWS-{UUID}` and four other
* X-* headers (api-path, gui-version, user-id, origin, referer); we intercept
* all of them so backend requests can replay the exact header set.
*
* Tokens are IP-bound — same proxy port must be reused for the 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 {
@@ -24,6 +23,7 @@ import { ConfigService } from "@nestjs/config";
import type { Browser, BrowserContext } from "playwright";
import type { PcatJwtToken, JwtSlot, PcatSession } from "./parts-catalogs.types";
const TOKEN_TTL = 600; // seconds — TWS- has no built-in expiry, refresh aggressively
const REFRESH_BUFFER = 90; // Refresh 90s before expiry
const CAPTURE_POLL_INTERVAL = 500; // ms
const CAPTURE_POLL_MAX = 40; // 40 × 500ms = 20s max wait
@@ -32,11 +32,11 @@ 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
const RPM_PER_SLOT = 6; // 1 token per 6 req/min
/**
* Sites that embed the parts-catalogs.com widget.
* Widget loads JS → calls /api/start → then calls /v1/catalogs/ with JWT.
* Sites embedding the parts-catalogs.com v3 widget.
* Widget loads JS → calls /v3/api/proxy/* with x-api-key + supporting X-* headers.
* Each site uses a different proxy port (IP) to avoid rate limiting.
*/
const JWT_SITES = [
@@ -50,7 +50,6 @@ const JWT_SITES = [
"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)
@@ -58,7 +57,7 @@ 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";
const DI_DEFAULT_PASS = "78ebc3d881de6ec0";
/** Simple counting semaphore (same pattern as EmexBrowserService) */
class Semaphore {
@@ -234,7 +233,12 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
}
: null;
return {
authorization: slot.jwt.raw,
apiKey: slot.jwt.raw,
apiPath: slot.jwt.apiPath,
guiVersion: slot.jwt.guiVersion,
userId: slot.jwt.userId,
origin: slot.jwt.origin,
referer: slot.jwt.referer,
proxyUrl,
proxyConfig,
_slot: slot,
@@ -513,22 +517,28 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
context = await this.browser!.newContext(contextOptions);
const page = await context.newPage();
// Intercept requests to parts-catalogs.com
let capturedJwt: string | null = null;
// Intercept the v3 widget call to /v3/api/proxy/* — needs the full
// header set (x-api-key + x-api-path + x-gui-version + x-user-id +
// origin + referer) to replay against gui.parts-catalogs.com.
let capturedToken: PcatJwtToken | null = null;
page.on("request", (request) => {
if (capturedJwt) return;
if (capturedToken) 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");
}
}
if (!/\/v3\/api\/proxy\//i.test(url)) return;
const h = request.headers();
const apiKey = h["x-api-key"];
if (!apiKey || !apiKey.startsWith("TWS-")) return;
capturedToken = {
raw: apiKey,
exp: Math.floor(Date.now() / 1000) + TOKEN_TTL,
apiPath: h["x-api-path"] || "https://api.parts-catalogs.com/v1",
guiVersion: h["x-gui-version"] || "3",
userId: h["x-user-id"] || "",
origin: h["origin"] || "",
referer: h["referer"] || "",
};
this.logger.debug(`Token intercepted (key=${apiKey.slice(0, 16)}...)`);
});
// Block heavy resources to save proxy bandwidth
@@ -569,26 +579,25 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
);
}
// Poll for JWT
// Poll for token
for (let i = 0; i < CAPTURE_POLL_MAX; i++) {
if (capturedJwt) break;
if (capturedToken) break;
await new Promise((r) => setTimeout(r, CAPTURE_POLL_INTERVAL));
}
const elapsed = Date.now() - startTime;
if (capturedJwt) {
const token = this.parseJwt(capturedJwt);
if (capturedToken) {
this.logger.log(
`JWT captured in ${elapsed}ms from ${new URL(siteUrl).hostname}`,
`Token captured in ${elapsed}ms from ${new URL(siteUrl).hostname}`,
);
return token;
return capturedToken;
}
this.logger.debug(`No JWT after ${elapsed}ms from ${siteUrl}`);
this.logger.debug(`No token after ${elapsed}ms from ${siteUrl}`);
return null;
} catch (err) {
this.logger.warn(`JWT capture error: ${(err as Error).message}`);
this.logger.warn(`Token capture error: ${(err as Error).message}`);
return null;
} finally {
if (context) {
@@ -604,34 +613,6 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
}
}
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> {

View File

@@ -1,8 +1,9 @@
/**
* Parts-Catalogs API Service — HTTP client for parts-catalogs.com
* Parts-Catalogs API Service — HTTP client for parts-catalogs.com v3
*
* All requests go through the same DataImpulse proxy as the JWT capture
* to ensure the JWT's IP-bound constraint is satisfied.
* Calls the v3 widget proxy (gui.parts-catalogs.com/v3/api/proxy/*) with the
* captured TWS- token + supporting X-* headers. Requests must go through the
* same DataImpulse proxy port that captured the token (IP-bound).
*/
import { Injectable, Logger } from "@nestjs/common";
@@ -16,7 +17,7 @@ import type {
PcatSession,
} from "./parts-catalogs.types";
const API_BASE = "https://api.parts-catalogs.com/v1";
const API_BASE = "https://gui.parts-catalogs.com/v3/api/proxy";
const REQUEST_TIMEOUT = 30_000;
@Injectable()
@@ -192,10 +193,15 @@ export class PartsCatalogsService {
const fetchOptions: RequestInit & { dispatcher?: any } = {
method: "GET",
headers: {
Authorization: session.authorization,
"x-api-key": session.apiKey,
"x-api-path": session.apiPath,
"x-gui-version": session.guiVersion,
"x-user-id": session.userId,
origin: session.origin,
referer: session.referer,
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",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT),
};

View File

@@ -1,11 +1,16 @@
/**
* Captured from a parts-catalogs.com v3 widget request.
* Token + the supporting X-* headers the widget sends with every API call.
* IP-bound (must be reused with the same proxy port that captured it).
*/
export interface PcatJwtToken {
raw: string;
exp: number;
host: string;
apiKey: string;
apiPath: string;
ip: string;
hash: string;
raw: string; // x-api-key value, e.g. "TWS-016EA7BE-..."
exp: number; // unix epoch seconds (capturedAt + TTL_FALLBACK)
apiPath: string; // x-api-path (upstream PCAT API base URL)
guiVersion: string; // x-gui-version (e.g. "3")
userId: string; // x-user-id (per-session UUID minted by widget)
origin: string; // partner-site origin
referer: string; // partner-site referer
}
export interface JwtSlot {
@@ -17,7 +22,12 @@ export interface JwtSlot {
}
export interface PcatSession {
authorization: string;
apiKey: string; // x-api-key (TWS- token)
apiPath: string;
guiVersion: string;
userId: string;
origin: string;
referer: string;
proxyUrl: string | null;
proxyConfig: { server: string; username: string; password: string } | null;
_slot: JwtSlot;