feat: sase.tr v2 full application implementation
Complete rewrite of sase.tr VIN lookup platform with modern stack: Backend (NestJS 10 + Drizzle ORM + PostgreSQL + Redis + BullMQ): - 34 DB models (core + PL24 + EMEX schemas) - Auth via Better Auth (email/password + social) - Brands, Plans, Subscriptions, Payments (iyzico + EFT) - VIN decode orchestration (Corgi + PL24 + EMEX + NHTSA) - Interactive schema viewer backend (MinIO storage) - EMEX scraping integration (Puppeteer + BullMQ workers) - Translation module (EN→TR automotive dictionary) - Admin dashboard API (stats, user mgmt, payment approval) - Rate limiting, Helmet security, file upload validation Frontend (Next.js 15 + Tailwind v4 + shadcn/ui + TanStack Query + Zustand): - 20 routes: auth, dashboard, VIN search, schema viewer, admin - Interactive schema viewer with zoom/pan/hotspot highlighting - Subscription management with brand selector - Payment flow (iyzico 3D Secure + EFT with receipt upload) - i18n support (TR/EN) - Error boundaries, loading skeletons, 404 page Infrastructure: - 85 tests (52 backend + 33 frontend, Vitest) - CI/CD (GitHub Actions: lint, typecheck, test, build, deploy) - Zero-downtime deploy script (PM2) - Env validation script Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
22
apps/api/src/integrations/emex/browser.d.ts
vendored
Normal file
22
apps/api/src/integrations/emex/browser.d.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Ambient type declarations for browser-context code used in Puppeteer evaluate().
|
||||
* These functions are serialized and executed inside Chromium, not in Node.js.
|
||||
* We declare minimal DOM types here to avoid adding "dom" to the global tsconfig lib.
|
||||
*/
|
||||
|
||||
interface Element {
|
||||
querySelector(selector: string): Element | null;
|
||||
querySelectorAll(selector: string): NodeListOf<Element>;
|
||||
getAttribute(name: string): string | null;
|
||||
textContent: string | null;
|
||||
}
|
||||
|
||||
interface NodeListOf<T> {
|
||||
forEach(callback: (value: T, index: number) => void): void;
|
||||
length: number;
|
||||
}
|
||||
|
||||
declare const document: {
|
||||
querySelector(selector: string): Element | null;
|
||||
querySelectorAll(selector: string): NodeListOf<Element>;
|
||||
};
|
||||
178
apps/api/src/integrations/emex/emex-parser.service.ts
Normal file
178
apps/api/src/integrations/emex/emex-parser.service.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import type {
|
||||
EmexVehicleData,
|
||||
EmexCategoryData,
|
||||
EmexPartData,
|
||||
} from "./emex.types";
|
||||
|
||||
@Injectable()
|
||||
export class EmexParserService {
|
||||
private readonly logger = new Logger(EmexParserService.name);
|
||||
|
||||
parseVehicleData(data: Record<string, unknown>): EmexVehicleData | null {
|
||||
try {
|
||||
const vehicleId = this.extractString(data, "vehicleId", "id", "vehicle_id");
|
||||
if (!vehicleId) {
|
||||
this.logger.warn("No vehicleId found in EMEX vehicle data");
|
||||
return null;
|
||||
}
|
||||
|
||||
const brandName = this.extractString(data, "brandName", "brand", "make") || "";
|
||||
const name = this.extractString(data, "name", "title", "vehicleName") || "";
|
||||
const modelCode = this.extractString(data, "modelCode", "model", "model_code");
|
||||
const engine = this.extractString(data, "engine", "engineCode", "engine_code");
|
||||
|
||||
const yearFrom = this.extractNumber(data, "yearFrom", "year_from", "startYear");
|
||||
const yearTo = this.extractNumber(data, "yearTo", "year_to", "endYear");
|
||||
|
||||
const catalogId = this.extractString(data, "catalogId", "catalog_id", "catalogueId") || "";
|
||||
|
||||
return {
|
||||
vehicleId,
|
||||
catalogId,
|
||||
brandName,
|
||||
name,
|
||||
modelCode,
|
||||
engine,
|
||||
yearFrom,
|
||||
yearTo,
|
||||
rawData: data,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to parse EMEX vehicle data", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
parseCategoryTree(data: unknown[]): EmexCategoryData[] {
|
||||
try {
|
||||
if (!Array.isArray(data)) {
|
||||
this.logger.warn("Invalid category data: expected array");
|
||||
return [];
|
||||
}
|
||||
|
||||
const categories: EmexCategoryData[] = [];
|
||||
|
||||
for (const item of data) {
|
||||
if (typeof item !== "object" || item === null) continue;
|
||||
|
||||
const record = item as Record<string, unknown>;
|
||||
const groupId = this.extractString(record, "groupId", "id", "group_id");
|
||||
const name = this.extractString(record, "name", "title", "groupName");
|
||||
|
||||
if (!groupId || !name) continue;
|
||||
|
||||
const category: EmexCategoryData = {
|
||||
groupId,
|
||||
name,
|
||||
nameOriginal: this.extractString(record, "nameOriginal", "name_original", "originalName"),
|
||||
parentGroupId: this.extractString(record, "parentGroupId", "parent_group_id", "parentId"),
|
||||
sortOrder: this.extractNumber(record, "sortOrder", "sort_order", "order"),
|
||||
};
|
||||
|
||||
categories.push(category);
|
||||
|
||||
// Recursively parse children if present
|
||||
const children = record.children || record.subGroups || record.sub_groups;
|
||||
if (Array.isArray(children) && children.length > 0) {
|
||||
const childCategories = this.parseCategoryTree(
|
||||
children.map((child: unknown) => ({
|
||||
...(child as Record<string, unknown>),
|
||||
parentGroupId: groupId,
|
||||
})),
|
||||
);
|
||||
categories.push(...childCategories);
|
||||
}
|
||||
}
|
||||
|
||||
return categories;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to parse EMEX category tree", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
parsePartsTable(data: unknown[]): EmexPartData[] {
|
||||
try {
|
||||
if (!Array.isArray(data)) {
|
||||
this.logger.warn("Invalid parts data: expected array");
|
||||
return [];
|
||||
}
|
||||
|
||||
const parts: EmexPartData[] = [];
|
||||
|
||||
for (const item of data) {
|
||||
if (typeof item !== "object" || item === null) continue;
|
||||
|
||||
const record = item as Record<string, unknown>;
|
||||
const name = this.extractString(record, "name", "title", "partName");
|
||||
|
||||
if (!name) continue;
|
||||
|
||||
// Extract OEM codes
|
||||
const oemCodes: string[] = [];
|
||||
const rawOem = record.oemCodes || record.oem_codes || record.partNumbers || record.codes;
|
||||
if (Array.isArray(rawOem)) {
|
||||
for (const code of rawOem) {
|
||||
if (typeof code === "string" && code.trim()) {
|
||||
oemCodes.push(code.trim());
|
||||
} else if (typeof code === "object" && code !== null) {
|
||||
const codeStr = (code as Record<string, unknown>).code || (code as Record<string, unknown>).value;
|
||||
if (typeof codeStr === "string" && codeStr.trim()) {
|
||||
oemCodes.push(codeStr.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (typeof rawOem === "string" && rawOem.trim()) {
|
||||
oemCodes.push(rawOem.trim());
|
||||
}
|
||||
|
||||
// Try to extract single OEM code field
|
||||
const singleOem = this.extractString(record, "oemCode", "oem_code", "partNumber");
|
||||
if (singleOem && !oemCodes.includes(singleOem)) {
|
||||
oemCodes.unshift(singleOem);
|
||||
}
|
||||
|
||||
parts.push({
|
||||
partId: this.extractString(record, "partId", "id", "part_id"),
|
||||
name,
|
||||
nameOriginal: this.extractString(record, "nameOriginal", "name_original", "originalName"),
|
||||
description: this.extractString(record, "description", "desc", "note"),
|
||||
quantity: this.extractNumber(record, "quantity", "qty", "count"),
|
||||
position: this.extractString(record, "position", "pos", "location"),
|
||||
hotspotIndex: this.extractNumber(record, "hotspotIndex", "hotspot_index", "hotspot"),
|
||||
oemCodes,
|
||||
});
|
||||
}
|
||||
|
||||
return parts;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to parse EMEX parts table", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private extractString(data: Record<string, unknown>, ...keys: string[]): string | null {
|
||||
for (const key of keys) {
|
||||
const value = data[key];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private extractNumber(data: Record<string, unknown>, ...keys: string[]): number | null {
|
||||
for (const key of keys) {
|
||||
const value = data[key];
|
||||
if (typeof value === "number" && !isNaN(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const parsed = parseInt(value, 10);
|
||||
if (!isNaN(parsed)) return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
136
apps/api/src/integrations/emex/emex-queue.service.ts
Normal file
136
apps/api/src/integrations/emex/emex-queue.service.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { Injectable, Logger, OnModuleDestroy } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Queue, type JobsOptions } from "bullmq";
|
||||
import { RedisService } from "../../redis/redis.service";
|
||||
import type { EmexScrapeJobData, EmexJobStatus } from "./emex.types";
|
||||
|
||||
const QUEUE_NAME = "emex-scrape";
|
||||
|
||||
const DEFAULT_JOB_OPTIONS: JobsOptions = {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 5000,
|
||||
},
|
||||
removeOnComplete: {
|
||||
age: 86400, // 24h
|
||||
count: 1000,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 604800, // 7 days
|
||||
count: 5000,
|
||||
},
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class EmexQueueService implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(EmexQueueService.name);
|
||||
private readonly queue: Queue<EmexScrapeJobData>;
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private redis: RedisService,
|
||||
) {
|
||||
const redisHost = this.configService.get<string>("redis.host", "127.0.0.1");
|
||||
const redisPort = this.configService.get<number>("redis.port", 6379);
|
||||
const redisPassword = this.configService.get<string>("redis.password");
|
||||
|
||||
this.queue = new Queue<EmexScrapeJobData>(QUEUE_NAME, {
|
||||
connection: {
|
||||
host: redisHost,
|
||||
port: redisPort,
|
||||
password: redisPassword,
|
||||
maxRetriesPerRequest: null,
|
||||
},
|
||||
defaultJobOptions: DEFAULT_JOB_OPTIONS,
|
||||
});
|
||||
|
||||
this.logger.log(`EMEX scrape queue initialized: ${QUEUE_NAME}`);
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.queue.close();
|
||||
this.logger.log("EMEX scrape queue closed");
|
||||
}
|
||||
|
||||
async addScrapeJob(vin: string, userId: string): Promise<string> {
|
||||
const jobData: EmexScrapeJobData = {
|
||||
vin,
|
||||
userId,
|
||||
type: "full-decode",
|
||||
};
|
||||
|
||||
const job = await this.queue.add("decode-vin", jobData, {
|
||||
jobId: `emex-decode:${vin}:${Date.now()}`,
|
||||
priority: 1,
|
||||
});
|
||||
|
||||
this.logger.log(`Added EMEX scrape job for VIN: ${vin}, jobId: ${job.id}`);
|
||||
return job.id!;
|
||||
}
|
||||
|
||||
async addCategoriesJob(
|
||||
emexVehicleId: string,
|
||||
vin: string,
|
||||
userId: string,
|
||||
): Promise<string> {
|
||||
const jobData: EmexScrapeJobData = {
|
||||
vin,
|
||||
userId,
|
||||
type: "categories",
|
||||
emexVehicleId,
|
||||
};
|
||||
|
||||
const job = await this.queue.add("scrape-categories", jobData, {
|
||||
jobId: `emex-categories:${emexVehicleId}:${Date.now()}`,
|
||||
priority: 2,
|
||||
});
|
||||
|
||||
this.logger.log(`Added EMEX categories job for vehicleId: ${emexVehicleId}, jobId: ${job.id}`);
|
||||
return job.id!;
|
||||
}
|
||||
|
||||
async addPartsJob(
|
||||
emexVehicleId: string,
|
||||
groupId: string,
|
||||
vin: string,
|
||||
userId: string,
|
||||
): Promise<string> {
|
||||
const jobData: EmexScrapeJobData = {
|
||||
vin,
|
||||
userId,
|
||||
type: "parts",
|
||||
emexVehicleId,
|
||||
groupId,
|
||||
};
|
||||
|
||||
const job = await this.queue.add("scrape-parts", jobData, {
|
||||
jobId: `emex-parts:${emexVehicleId}:${groupId}:${Date.now()}`,
|
||||
priority: 3,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Added EMEX parts job for vehicleId: ${emexVehicleId}, groupId: ${groupId}, jobId: ${job.id}`,
|
||||
);
|
||||
return job.id!;
|
||||
}
|
||||
|
||||
async getJobStatus(jobId: string): Promise<EmexJobStatus | null> {
|
||||
const job = await this.queue.getJob(jobId);
|
||||
if (!job) return null;
|
||||
|
||||
const state = await job.getState();
|
||||
|
||||
return {
|
||||
jobId: job.id!,
|
||||
status: state as EmexJobStatus["status"],
|
||||
progress: typeof job.progress === "number" ? job.progress : 0,
|
||||
result: state === "completed" ? (job.returnvalue as EmexJobStatus["result"]) : null,
|
||||
failedReason: job.failedReason || null,
|
||||
};
|
||||
}
|
||||
|
||||
getQueue(): Queue<EmexScrapeJobData> {
|
||||
return this.queue;
|
||||
}
|
||||
}
|
||||
419
apps/api/src/integrations/emex/emex-scraper.service.ts
Normal file
419
apps/api/src/integrations/emex/emex-scraper.service.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
import { Injectable, Logger, OnModuleDestroy } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import type { Browser, Page } from "puppeteer";
|
||||
import puppeteer from "puppeteer";
|
||||
import { EmexParserService } from "./emex-parser.service";
|
||||
import {
|
||||
EmexCaptchaError,
|
||||
EmexScraperError,
|
||||
type EmexVehicleData,
|
||||
type EmexCategoryData,
|
||||
type EmexPartData,
|
||||
type EmexCredentials,
|
||||
} from "./emex.types";
|
||||
|
||||
const MAX_CONCURRENT_PAGES = 3;
|
||||
const MAX_RETRIES = 3;
|
||||
const PAGE_TIMEOUT = 30_000;
|
||||
const EMEX_BASE_URL = "https://emex.ru";
|
||||
|
||||
@Injectable()
|
||||
export class EmexScraperService implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(EmexScraperService.name);
|
||||
private browser: Browser | null = null;
|
||||
private activePagesCount = 0;
|
||||
private readonly pageQueue: Array<{
|
||||
resolve: (page: Page) => void;
|
||||
reject: (error: Error) => void;
|
||||
}> = [];
|
||||
private readonly credentials: EmexCredentials;
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private parser: EmexParserService,
|
||||
) {
|
||||
this.credentials = {
|
||||
username: this.configService.get<string>("emex.username") || "",
|
||||
password: this.configService.get<string>("emex.password") || "",
|
||||
};
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.closeBrowser();
|
||||
}
|
||||
|
||||
async scrapeVehicle(vin: string): Promise<EmexVehicleData | null> {
|
||||
return this.withRetry(`scrapeVehicle(${vin})`, async () => {
|
||||
const page = await this.acquirePage();
|
||||
try {
|
||||
await this.ensureLoggedIn(page);
|
||||
|
||||
await page.goto(`${EMEX_BASE_URL}/catalogs/decode?vin=${encodeURIComponent(vin)}`, {
|
||||
waitUntil: "networkidle2",
|
||||
timeout: PAGE_TIMEOUT,
|
||||
});
|
||||
|
||||
this.detectCaptcha(page);
|
||||
|
||||
const vehicleData = await page.evaluate(this.extractVehicleFromPage);
|
||||
|
||||
if (!vehicleData) {
|
||||
this.logger.warn(`No vehicle data found for VIN: ${vin}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.parser.parseVehicleData(vehicleData);
|
||||
} finally {
|
||||
await this.releasePage(page);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async scrapeCategories(emexVehicleId: string): Promise<EmexCategoryData[]> {
|
||||
return this.withRetry(`scrapeCategories(${emexVehicleId})`, async () => {
|
||||
const page = await this.acquirePage();
|
||||
try {
|
||||
await this.ensureLoggedIn(page);
|
||||
|
||||
await page.goto(
|
||||
`${EMEX_BASE_URL}/catalogs/vehicle/${encodeURIComponent(emexVehicleId)}/groups`,
|
||||
{
|
||||
waitUntil: "networkidle2",
|
||||
timeout: PAGE_TIMEOUT,
|
||||
},
|
||||
);
|
||||
|
||||
this.detectCaptcha(page);
|
||||
|
||||
const categoriesData = await page.evaluate(this.extractCategoriesFromPage);
|
||||
|
||||
return this.parser.parseCategoryTree(categoriesData);
|
||||
} finally {
|
||||
await this.releasePage(page);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async scrapeParts(emexVehicleId: string, groupId: string): Promise<EmexPartData[]> {
|
||||
return this.withRetry(`scrapeParts(${emexVehicleId}, ${groupId})`, async () => {
|
||||
const page = await this.acquirePage();
|
||||
try {
|
||||
await this.ensureLoggedIn(page);
|
||||
|
||||
await page.goto(
|
||||
`${EMEX_BASE_URL}/catalogs/vehicle/${encodeURIComponent(emexVehicleId)}/groups/${encodeURIComponent(groupId)}/parts`,
|
||||
{
|
||||
waitUntil: "networkidle2",
|
||||
timeout: PAGE_TIMEOUT,
|
||||
},
|
||||
);
|
||||
|
||||
this.detectCaptcha(page);
|
||||
|
||||
const partsData = await page.evaluate(this.extractPartsFromPage);
|
||||
|
||||
return this.parser.parsePartsTable(partsData);
|
||||
} finally {
|
||||
await this.releasePage(page);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-context function: extracts vehicle data from the EMEX decode page.
|
||||
* Serialized and sent to Puppeteer's evaluate — runs inside Chromium, not Node.
|
||||
*/
|
||||
private extractVehicleFromPage(): Record<string, unknown> | null {
|
||||
const vehicleInfo = document.querySelector(
|
||||
"[data-vehicle-info], .vehicle-info, .decode-result",
|
||||
);
|
||||
if (!vehicleInfo) return null;
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
data.vehicleId = vehicleInfo.getAttribute("data-vehicle-id") || "";
|
||||
data.catalogId = vehicleInfo.getAttribute("data-catalog-id") || "";
|
||||
|
||||
const fields = vehicleInfo.querySelectorAll("[data-field], .info-row, tr");
|
||||
fields.forEach((field: Element) => {
|
||||
const label =
|
||||
field.querySelector(".label, th, [data-label]")?.textContent?.trim().toLowerCase() || "";
|
||||
const value =
|
||||
field.querySelector(".value, td, [data-value]")?.textContent?.trim() || "";
|
||||
|
||||
if (label.includes("brand") || label.includes("marka")) data.brandName = value;
|
||||
if (label.includes("model")) data.modelCode = value;
|
||||
if (label.includes("name") || label.includes("ad")) data.name = value;
|
||||
if (label.includes("engine") || label.includes("motor")) data.engine = value;
|
||||
if (label.includes("year") || label.includes("yil") || label.includes("yıl")) {
|
||||
const years = value.match(/(\d{4})/g);
|
||||
if (years) {
|
||||
data.yearFrom = parseInt(years[0], 10);
|
||||
if (years.length > 1) data.yearTo = parseInt(years[1], 10);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-context function: extracts category groups from the EMEX groups page.
|
||||
*/
|
||||
private extractCategoriesFromPage(): Record<string, unknown>[] {
|
||||
const groups: Record<string, unknown>[] = [];
|
||||
|
||||
const groupElements = document.querySelectorAll(
|
||||
"[data-group], .group-item, .category-item, .tree-node",
|
||||
);
|
||||
|
||||
groupElements.forEach((el: Element, index: number) => {
|
||||
const group: Record<string, unknown> = {};
|
||||
group.groupId =
|
||||
el.getAttribute("data-group-id") || el.getAttribute("data-id") || `group-${index}`;
|
||||
group.name =
|
||||
el.querySelector(".group-name, .name, .title")?.textContent?.trim() || "";
|
||||
group.nameOriginal = el.getAttribute("data-original-name") || null;
|
||||
group.parentGroupId = el.getAttribute("data-parent-id") || null;
|
||||
group.sortOrder = index;
|
||||
|
||||
if (group.name) {
|
||||
groups.push(group);
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-context function: extracts parts from the EMEX parts page.
|
||||
*/
|
||||
private extractPartsFromPage(): Record<string, unknown>[] {
|
||||
const parts: Record<string, unknown>[] = [];
|
||||
|
||||
const partRows = document.querySelectorAll(
|
||||
"[data-part], .part-row, .parts-table tbody tr, .part-item",
|
||||
);
|
||||
|
||||
partRows.forEach((row: Element) => {
|
||||
const part: Record<string, unknown> = {};
|
||||
part.partId =
|
||||
row.getAttribute("data-part-id") || row.getAttribute("data-id") || null;
|
||||
part.name =
|
||||
row.querySelector(".part-name, .name, td:nth-child(2)")?.textContent?.trim() || "";
|
||||
part.nameOriginal = row.getAttribute("data-original-name") || null;
|
||||
part.description =
|
||||
row.querySelector(".part-desc, .description, td:nth-child(3)")?.textContent?.trim() ||
|
||||
null;
|
||||
|
||||
const qtyText = row
|
||||
.querySelector(".part-qty, .quantity, td:nth-child(4)")
|
||||
?.textContent?.trim();
|
||||
part.quantity = qtyText ? parseInt(qtyText, 10) : null;
|
||||
|
||||
part.position =
|
||||
row.querySelector(".part-position, .position")?.textContent?.trim() || null;
|
||||
|
||||
const hotspotAttr = row.getAttribute("data-hotspot");
|
||||
part.hotspotIndex = hotspotAttr ? parseInt(hotspotAttr, 10) : null;
|
||||
|
||||
// Extract OEM codes
|
||||
const oemElements = row.querySelectorAll(".oem-code, .part-number, [data-oem]");
|
||||
const codes: string[] = [];
|
||||
oemElements.forEach((el: Element) => {
|
||||
const code = el.textContent?.trim();
|
||||
if (code) codes.push(code);
|
||||
});
|
||||
part.oemCodes = codes;
|
||||
|
||||
// Fallback: single OEM code field
|
||||
if (codes.length === 0) {
|
||||
const singleOem = row.querySelector("td:first-child")?.textContent?.trim();
|
||||
if (singleOem) part.oemCode = singleOem;
|
||||
}
|
||||
|
||||
if (part.name) {
|
||||
parts.push(part);
|
||||
}
|
||||
});
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
private async ensureLoggedIn(page: Page): Promise<void> {
|
||||
if (!this.credentials.username || !this.credentials.password) {
|
||||
throw new EmexScraperError("EMEX credentials not configured", false);
|
||||
}
|
||||
|
||||
// Check if already logged in by looking for session cookie
|
||||
const cookies = await page.cookies(EMEX_BASE_URL);
|
||||
const sessionCookie = cookies.find(
|
||||
(c) => c.name === "session" || c.name === "PHPSESSID" || c.name === "auth_token",
|
||||
);
|
||||
|
||||
if (sessionCookie) return;
|
||||
|
||||
await page.goto(`${EMEX_BASE_URL}/login`, {
|
||||
waitUntil: "networkidle2",
|
||||
timeout: PAGE_TIMEOUT,
|
||||
});
|
||||
|
||||
this.detectCaptcha(page);
|
||||
|
||||
await page.type(
|
||||
'input[name="username"], input[name="email"], input[name="login"], #username, #email',
|
||||
this.credentials.username,
|
||||
);
|
||||
|
||||
await page.type(
|
||||
'input[name="password"], input[type="password"], #password',
|
||||
this.credentials.password,
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
page.waitForNavigation({ waitUntil: "networkidle2", timeout: PAGE_TIMEOUT }),
|
||||
page.click('button[type="submit"], input[type="submit"], .login-btn, #login-btn'),
|
||||
]);
|
||||
|
||||
this.detectCaptcha(page);
|
||||
|
||||
this.logger.log("Successfully logged in to EMEX");
|
||||
}
|
||||
|
||||
private detectCaptcha(page: Page): void {
|
||||
// Synchronous check of page URL for captcha indicators
|
||||
const url = page.url();
|
||||
if (url.includes("captcha") || url.includes("challenge")) {
|
||||
throw new EmexCaptchaError();
|
||||
}
|
||||
}
|
||||
|
||||
private async withRetry<T>(operation: string, fn: () => Promise<T>): Promise<T> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
|
||||
if (error instanceof EmexCaptchaError) {
|
||||
this.logger.error(`CAPTCHA detected during ${operation}, cannot retry`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (error instanceof EmexScraperError && !error.retryable) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
`Attempt ${attempt}/${MAX_RETRIES} failed for ${operation}: ${lastError.message}`,
|
||||
);
|
||||
|
||||
if (attempt < MAX_RETRIES) {
|
||||
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10_000);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new EmexScraperError(
|
||||
`${operation} failed after ${MAX_RETRIES} attempts: ${lastError?.message}`,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
private async getBrowser(): Promise<Browser> {
|
||||
if (!this.browser || !this.browser.connected) {
|
||||
this.browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
"--disable-extensions",
|
||||
"--single-process",
|
||||
],
|
||||
});
|
||||
|
||||
this.browser.on("disconnected", () => {
|
||||
this.logger.warn("Browser disconnected");
|
||||
this.browser = null;
|
||||
this.activePagesCount = 0;
|
||||
});
|
||||
|
||||
this.logger.log("Puppeteer browser launched");
|
||||
}
|
||||
|
||||
return this.browser;
|
||||
}
|
||||
|
||||
private async acquirePage(): Promise<Page> {
|
||||
if (this.activePagesCount >= MAX_CONCURRENT_PAGES) {
|
||||
return new Promise<Page>((resolve, reject) => {
|
||||
this.pageQueue.push({ resolve, reject });
|
||||
});
|
||||
}
|
||||
|
||||
this.activePagesCount++;
|
||||
|
||||
try {
|
||||
const browser = await this.getBrowser();
|
||||
const page = await browser.newPage();
|
||||
|
||||
await page.setDefaultTimeout(PAGE_TIMEOUT);
|
||||
await page.setDefaultNavigationTimeout(PAGE_TIMEOUT);
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
await page.setUserAgent(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
);
|
||||
|
||||
return page;
|
||||
} catch (error) {
|
||||
this.activePagesCount--;
|
||||
this.processPageQueue();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async releasePage(page: Page): Promise<void> {
|
||||
try {
|
||||
if (!page.isClosed()) {
|
||||
await page.close();
|
||||
}
|
||||
} catch {
|
||||
// Page may already be closed
|
||||
}
|
||||
|
||||
this.activePagesCount--;
|
||||
this.processPageQueue();
|
||||
}
|
||||
|
||||
private processPageQueue(): void {
|
||||
if (this.pageQueue.length > 0 && this.activePagesCount < MAX_CONCURRENT_PAGES) {
|
||||
const next = this.pageQueue.shift();
|
||||
if (next) {
|
||||
this.acquirePage().then(next.resolve).catch(next.reject);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async closeBrowser(): Promise<void> {
|
||||
// Reject queued page requests
|
||||
for (const queued of this.pageQueue) {
|
||||
queued.reject(new Error("Browser closing"));
|
||||
}
|
||||
this.pageQueue.length = 0;
|
||||
|
||||
if (this.browser) {
|
||||
try {
|
||||
await this.browser.close();
|
||||
} catch {
|
||||
// Browser may already be closed
|
||||
}
|
||||
this.browser = null;
|
||||
this.activePagesCount = 0;
|
||||
this.logger.log("Puppeteer browser closed");
|
||||
}
|
||||
}
|
||||
}
|
||||
11
apps/api/src/integrations/emex/emex.module.ts
Normal file
11
apps/api/src/integrations/emex/emex.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { EmexService } from "./emex.service";
|
||||
import { EmexScraperService } from "./emex-scraper.service";
|
||||
import { EmexParserService } from "./emex-parser.service";
|
||||
import { EmexQueueService } from "./emex-queue.service";
|
||||
|
||||
@Module({
|
||||
providers: [EmexService, EmexScraperService, EmexParserService, EmexQueueService],
|
||||
exports: [EmexService],
|
||||
})
|
||||
export class EmexModule {}
|
||||
371
apps/api/src/integrations/emex/emex.service.ts
Normal file
371
apps/api/src/integrations/emex/emex.service.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { DATABASE, type Database } from "../../database/database.provider";
|
||||
import { RedisService } from "../../redis/redis.service";
|
||||
import { EmexScraperService } from "./emex-scraper.service";
|
||||
import { EmexQueueService } from "./emex-queue.service";
|
||||
import {
|
||||
emexVehicles,
|
||||
emexVehicleVins,
|
||||
emexPartGroups,
|
||||
emexParts,
|
||||
emexPartNumbers,
|
||||
emexCatalogs,
|
||||
emexScrapeSessions,
|
||||
} from "../../database/schema/emex";
|
||||
import type {
|
||||
EmexVehicleData,
|
||||
EmexCategoryData,
|
||||
EmexPartData,
|
||||
EmexJobStatus,
|
||||
} from "./emex.types";
|
||||
|
||||
const CACHE_PREFIX = "emex:";
|
||||
const VEHICLE_CACHE_TTL = 86400; // 24h
|
||||
const CATEGORY_CACHE_TTL = 3600; // 1h
|
||||
const PARTS_CACHE_TTL = 3600; // 1h
|
||||
|
||||
@Injectable()
|
||||
export class EmexService {
|
||||
private readonly logger = new Logger(EmexService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE) private db: Database,
|
||||
private redis: RedisService,
|
||||
private scraper: EmexScraperService,
|
||||
private queue: EmexQueueService,
|
||||
) {}
|
||||
|
||||
async decodeVin(vin: string, userId: string): Promise<{ jobId: string }> {
|
||||
// Check if we already have a recent scrape session
|
||||
const existingSession = await this.db
|
||||
.select()
|
||||
.from(emexScrapeSessions)
|
||||
.where(and(eq(emexScrapeSessions.vin, vin), eq(emexScrapeSessions.status, "pending")))
|
||||
.limit(1);
|
||||
|
||||
if (existingSession.length > 0 && existingSession[0].jobId) {
|
||||
this.logger.log(`Reusing existing scrape session for VIN: ${vin}`);
|
||||
return { jobId: existingSession[0].jobId };
|
||||
}
|
||||
|
||||
const jobId = await this.queue.addScrapeJob(vin, userId);
|
||||
|
||||
// Create scrape session record
|
||||
await this.db.insert(emexScrapeSessions).values({
|
||||
vin,
|
||||
status: "pending",
|
||||
jobId,
|
||||
startedAt: new Date(),
|
||||
});
|
||||
|
||||
return { jobId };
|
||||
}
|
||||
|
||||
async getJobStatus(jobId: string): Promise<EmexJobStatus | null> {
|
||||
return this.queue.getJobStatus(jobId);
|
||||
}
|
||||
|
||||
async getScrapedVehicle(vin: string): Promise<EmexVehicleData | null> {
|
||||
// Redis cache check
|
||||
const cacheKey = `${CACHE_PREFIX}vehicle:${vin}`;
|
||||
const cached = await this.redis.getJson<EmexVehicleData>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
// Database check via VIN link
|
||||
const vinRecord = await this.db
|
||||
.select()
|
||||
.from(emexVehicleVins)
|
||||
.where(eq(emexVehicleVins.vin, vin))
|
||||
.limit(1);
|
||||
|
||||
if (vinRecord.length === 0 || !vinRecord[0].emexVehicleId) return null;
|
||||
|
||||
const vehicleRecord = await this.db
|
||||
.select()
|
||||
.from(emexVehicles)
|
||||
.where(eq(emexVehicles.id, vinRecord[0].emexVehicleId))
|
||||
.limit(1);
|
||||
|
||||
if (vehicleRecord.length === 0) return null;
|
||||
|
||||
const vehicle = vehicleRecord[0];
|
||||
const result: EmexVehicleData = {
|
||||
vehicleId: vehicle.vehicleId,
|
||||
catalogId: vehicle.catalogId || "",
|
||||
brandName: "",
|
||||
name: vehicle.name || "",
|
||||
modelCode: vehicle.modelCode || null,
|
||||
engine: vehicle.engine || null,
|
||||
yearFrom: vehicle.yearFrom || null,
|
||||
yearTo: vehicle.yearTo || null,
|
||||
rawData: (vehicle.rawData as Record<string, unknown>) || null,
|
||||
};
|
||||
|
||||
// Resolve brand name from catalog
|
||||
if (vehicle.catalogId) {
|
||||
const catalog = await this.db
|
||||
.select()
|
||||
.from(emexCatalogs)
|
||||
.where(eq(emexCatalogs.id, vehicle.catalogId))
|
||||
.limit(1);
|
||||
|
||||
if (catalog.length > 0) {
|
||||
result.brandName = catalog[0].brandName;
|
||||
}
|
||||
}
|
||||
|
||||
await this.redis.setJson(cacheKey, result, VEHICLE_CACHE_TTL);
|
||||
return result;
|
||||
}
|
||||
|
||||
async getScrapedCategories(vehicleId: string): Promise<EmexCategoryData[]> {
|
||||
const cacheKey = `${CACHE_PREFIX}categories:${vehicleId}`;
|
||||
const cached = await this.redis.getJson<EmexCategoryData[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
// Look up the internal UUID from the emex vehicleId string
|
||||
const vehicleRecord = await this.db
|
||||
.select()
|
||||
.from(emexVehicles)
|
||||
.where(eq(emexVehicles.vehicleId, vehicleId))
|
||||
.limit(1);
|
||||
|
||||
if (vehicleRecord.length === 0) return [];
|
||||
|
||||
const emexVehicleUuid = vehicleRecord[0].id;
|
||||
|
||||
const groups = await this.db
|
||||
.select()
|
||||
.from(emexPartGroups)
|
||||
.where(eq(emexPartGroups.emexVehicleId, emexVehicleUuid));
|
||||
|
||||
const categories: EmexCategoryData[] = groups.map((g) => ({
|
||||
groupId: g.groupId,
|
||||
name: g.name,
|
||||
nameOriginal: g.nameOriginal || null,
|
||||
parentGroupId: g.parentGroupId || null,
|
||||
sortOrder: g.sortOrder || null,
|
||||
}));
|
||||
|
||||
if (categories.length > 0) {
|
||||
await this.redis.setJson(cacheKey, categories, CATEGORY_CACHE_TTL);
|
||||
}
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
async getScrapedParts(vehicleId: string, groupId: string): Promise<EmexPartData[]> {
|
||||
const cacheKey = `${CACHE_PREFIX}parts:${vehicleId}:${groupId}`;
|
||||
const cached = await this.redis.getJson<EmexPartData[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
// Look up internal UUIDs
|
||||
const vehicleRecord = await this.db
|
||||
.select()
|
||||
.from(emexVehicles)
|
||||
.where(eq(emexVehicles.vehicleId, vehicleId))
|
||||
.limit(1);
|
||||
|
||||
if (vehicleRecord.length === 0) return [];
|
||||
|
||||
const emexVehicleUuid = vehicleRecord[0].id;
|
||||
|
||||
const groupRecord = await this.db
|
||||
.select()
|
||||
.from(emexPartGroups)
|
||||
.where(
|
||||
and(
|
||||
eq(emexPartGroups.emexVehicleId, emexVehicleUuid),
|
||||
eq(emexPartGroups.groupId, groupId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (groupRecord.length === 0) return [];
|
||||
|
||||
const groupUuid = groupRecord[0].id;
|
||||
|
||||
// Fetch parts for this group
|
||||
const partsRecords = await this.db
|
||||
.select()
|
||||
.from(emexParts)
|
||||
.where(
|
||||
and(
|
||||
eq(emexParts.emexVehicleId, emexVehicleUuid),
|
||||
eq(emexParts.groupId, groupUuid),
|
||||
),
|
||||
);
|
||||
|
||||
// Fetch OEM codes for each part
|
||||
const parts: EmexPartData[] = await Promise.all(
|
||||
partsRecords.map(async (part) => {
|
||||
const partNumbers = await this.db
|
||||
.select()
|
||||
.from(emexPartNumbers)
|
||||
.where(eq(emexPartNumbers.emexPartId, part.id));
|
||||
|
||||
return {
|
||||
partId: part.partId || null,
|
||||
name: part.name,
|
||||
nameOriginal: part.nameOriginal || null,
|
||||
description: part.description || null,
|
||||
quantity: part.quantity || null,
|
||||
position: part.position || null,
|
||||
hotspotIndex: part.hotspotIndex || null,
|
||||
oemCodes: partNumbers.map((pn) => pn.oemCode),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
if (parts.length > 0) {
|
||||
await this.redis.setJson(cacheKey, parts, PARTS_CACHE_TTL);
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
async saveScrapedVehicle(vin: string, data: EmexVehicleData): Promise<string> {
|
||||
// Upsert catalog
|
||||
let catalogUuid: string | null = null;
|
||||
if (data.catalogId) {
|
||||
const existingCatalog = await this.db
|
||||
.select()
|
||||
.from(emexCatalogs)
|
||||
.where(eq(emexCatalogs.catalogId, data.catalogId))
|
||||
.limit(1);
|
||||
|
||||
if (existingCatalog.length > 0) {
|
||||
catalogUuid = existingCatalog[0].id;
|
||||
} else {
|
||||
const [inserted] = await this.db
|
||||
.insert(emexCatalogs)
|
||||
.values({
|
||||
catalogId: data.catalogId,
|
||||
brandName: data.brandName,
|
||||
})
|
||||
.returning();
|
||||
catalogUuid = inserted.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert vehicle
|
||||
const existingVehicle = await this.db
|
||||
.select()
|
||||
.from(emexVehicles)
|
||||
.where(eq(emexVehicles.vehicleId, data.vehicleId))
|
||||
.limit(1);
|
||||
|
||||
let vehicleUuid: string;
|
||||
if (existingVehicle.length > 0) {
|
||||
vehicleUuid = existingVehicle[0].id;
|
||||
await this.db
|
||||
.update(emexVehicles)
|
||||
.set({
|
||||
catalogId: catalogUuid,
|
||||
name: data.name,
|
||||
modelCode: data.modelCode,
|
||||
engine: data.engine,
|
||||
yearFrom: data.yearFrom,
|
||||
yearTo: data.yearTo,
|
||||
rawData: data.rawData,
|
||||
})
|
||||
.where(eq(emexVehicles.id, vehicleUuid));
|
||||
} else {
|
||||
const [inserted] = await this.db
|
||||
.insert(emexVehicles)
|
||||
.values({
|
||||
vehicleId: data.vehicleId,
|
||||
catalogId: catalogUuid,
|
||||
name: data.name,
|
||||
modelCode: data.modelCode,
|
||||
engine: data.engine,
|
||||
yearFrom: data.yearFrom,
|
||||
yearTo: data.yearTo,
|
||||
rawData: data.rawData,
|
||||
})
|
||||
.returning();
|
||||
vehicleUuid = inserted.id;
|
||||
}
|
||||
|
||||
// Link VIN to vehicle
|
||||
const existingVinLink = await this.db
|
||||
.select()
|
||||
.from(emexVehicleVins)
|
||||
.where(eq(emexVehicleVins.vin, vin))
|
||||
.limit(1);
|
||||
|
||||
if (existingVinLink.length === 0) {
|
||||
await this.db.insert(emexVehicleVins).values({
|
||||
emexVehicleId: vehicleUuid,
|
||||
vin,
|
||||
});
|
||||
}
|
||||
|
||||
// Invalidate cache
|
||||
await this.redis.del(`${CACHE_PREFIX}vehicle:${vin}`);
|
||||
|
||||
return vehicleUuid;
|
||||
}
|
||||
|
||||
async saveScrapedCategories(
|
||||
emexVehicleUuid: string,
|
||||
categories: EmexCategoryData[],
|
||||
): Promise<void> {
|
||||
for (const category of categories) {
|
||||
const existing = await this.db
|
||||
.select()
|
||||
.from(emexPartGroups)
|
||||
.where(
|
||||
and(
|
||||
eq(emexPartGroups.emexVehicleId, emexVehicleUuid),
|
||||
eq(emexPartGroups.groupId, category.groupId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
await this.db.insert(emexPartGroups).values({
|
||||
emexVehicleId: emexVehicleUuid,
|
||||
groupId: category.groupId,
|
||||
name: category.name,
|
||||
nameOriginal: category.nameOriginal,
|
||||
parentGroupId: category.parentGroupId,
|
||||
sortOrder: category.sortOrder,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async saveScrapedParts(
|
||||
emexVehicleUuid: string,
|
||||
groupUuid: string,
|
||||
parts: EmexPartData[],
|
||||
): Promise<void> {
|
||||
for (const part of parts) {
|
||||
const [insertedPart] = await this.db
|
||||
.insert(emexParts)
|
||||
.values({
|
||||
emexVehicleId: emexVehicleUuid,
|
||||
groupId: groupUuid,
|
||||
partId: part.partId,
|
||||
name: part.name,
|
||||
nameOriginal: part.nameOriginal,
|
||||
description: part.description,
|
||||
quantity: part.quantity,
|
||||
position: part.position,
|
||||
hotspotIndex: part.hotspotIndex,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Insert OEM codes
|
||||
for (let i = 0; i < part.oemCodes.length; i++) {
|
||||
await this.db.insert(emexPartNumbers).values({
|
||||
emexPartId: insertedPart.id,
|
||||
oemCode: part.oemCodes[i],
|
||||
isMain: i === 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
74
apps/api/src/integrations/emex/emex.types.ts
Normal file
74
apps/api/src/integrations/emex/emex.types.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
export interface EmexVehicleData {
|
||||
vehicleId: string;
|
||||
catalogId: string;
|
||||
brandName: string;
|
||||
name: string;
|
||||
modelCode: string | null;
|
||||
engine: string | null;
|
||||
yearFrom: number | null;
|
||||
yearTo: number | null;
|
||||
rawData: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface EmexCategoryData {
|
||||
groupId: string;
|
||||
name: string;
|
||||
nameOriginal: string | null;
|
||||
parentGroupId: string | null;
|
||||
sortOrder: number | null;
|
||||
}
|
||||
|
||||
export interface EmexPartData {
|
||||
partId: string | null;
|
||||
name: string;
|
||||
nameOriginal: string | null;
|
||||
description: string | null;
|
||||
quantity: number | null;
|
||||
position: string | null;
|
||||
hotspotIndex: number | null;
|
||||
oemCodes: string[];
|
||||
}
|
||||
|
||||
export interface EmexScrapeJobData {
|
||||
vin: string;
|
||||
userId: string;
|
||||
type: "full-decode" | "categories" | "parts";
|
||||
emexVehicleId?: string;
|
||||
groupId?: string;
|
||||
}
|
||||
|
||||
export interface EmexScrapeResult {
|
||||
vehicle: EmexVehicleData | null;
|
||||
categories: EmexCategoryData[];
|
||||
parts: EmexPartData[];
|
||||
}
|
||||
|
||||
export interface EmexJobStatus {
|
||||
jobId: string;
|
||||
status: "waiting" | "active" | "completed" | "failed" | "delayed";
|
||||
progress: number;
|
||||
result: EmexScrapeResult | null;
|
||||
failedReason: string | null;
|
||||
}
|
||||
|
||||
export interface EmexCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class EmexCaptchaError extends Error {
|
||||
constructor(message = "CAPTCHA detected on EMEX page") {
|
||||
super(message);
|
||||
this.name = "EmexCaptchaError";
|
||||
}
|
||||
}
|
||||
|
||||
export class EmexScraperError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly retryable = true,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "EmexScraperError";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user