Add scraper functionality and enhance product data extraction

- Updated `.gitignore` to exclude scraper intermediate data.
- Enhanced `justfile` with new scrape commands for raw data and LLM processing.
- Added OpenAI dependency in `scraper/package.json`.
- Modified `config.ts` to include OpenRouter API key and model.
- Expanded `extract.ts` to capture additional product details such as discount percentage, frost resistance, and size options.
- Updated `import.ts` to handle new product attributes during import.
- Enhanced `index.ts` to support raw scraping mode and improved product extraction logic.
- Updated `payload-types.ts` and `Products.ts` to include new product fields.
- Enhanced frontend components to display new product attributes and filters for availability and frost resistance.
- Improved error handling and logging in scraper functions.
- Added new features for managing product variants and specifications in the admin interface.
This commit is contained in:
Maxim Snesarev 2026-04-02 13:34:27 +03:00
parent a240d523e1
commit 8a8cfcd0f1
29 changed files with 1906 additions and 127 deletions

3
.gitignore vendored
View File

@ -8,6 +8,9 @@ dist/
*.tsbuildinfo
out/
# Scraper intermediate data
apps/scraper/data/
# MinIO data (dev)
minio-data/

View File

@ -0,0 +1,8 @@
# Payload CMS (for direct import mode)
PAYLOAD_API_URL=http://localhost:3000/api
PAYLOAD_EMAIL=admin@advdoors.ru
PAYLOAD_PASSWORD=
# OpenRouter (for LLM processing)
OPENROUTER_API_KEY=
OPENROUTER_MODEL=google/gemini-3.1-pro-preview

View File

@ -4,13 +4,17 @@
"private": true,
"type": "module",
"scripts": {
"dev": "tsx src/index.ts",
"dev": "tsx --env-file=.env src/index.ts",
"build": "tsc",
"scrape": "tsx src/index.ts"
"scrape": "tsx --env-file=.env src/index.ts",
"scrape:raw": "tsx --env-file=.env src/index.ts raw",
"llm:process": "tsx --env-file=.env src/process.ts",
"import:processed": "tsx --env-file=.env src/import-processed.ts"
},
"dependencies": {
"@advdoors/shared": "workspace:*",
"cheerio": "^1",
"openai": "^4.104.0",
"undici": "^7"
},
"devDependencies": {

View File

@ -21,6 +21,10 @@ export const PAYLOAD_API_URL =
process.env.PAYLOAD_API_URL || "http://localhost:3001/api";
export const PAYLOAD_EMAIL = process.env.PAYLOAD_EMAIL || "admin@advdoors.ru";
export const PAYLOAD_PASSWORD = process.env.PAYLOAD_PASSWORD || "";
export const PAYLOAD_PASSWORD = process.env.PAYLOAD_PASSWORD || "admin";
export const REQUEST_DELAY_MS = 500;
export const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY || "";
export const OPENROUTER_MODEL =
process.env.OPENROUTER_MODEL || "google/gemini-2.0-flash-001";

29
apps/scraper/src/dump.ts Normal file
View File

@ -0,0 +1,29 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { RawProduct } from "./llm/types.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_DIR = path.resolve(__dirname, "../data/raw");
function slugifyPath(text: string): string {
return text
.toLowerCase()
.replace(/[^a-zа-яё0-9]+/gi, "-")
.replace(/^-+|-+$/g, "")
.replace(/-+/g, "-");
}
export async function dumpCategory(
categoryName: string,
products: RawProduct[],
): Promise<string> {
await mkdir(DATA_DIR, { recursive: true });
const filename = `${slugifyPath(categoryName)}.json`;
const filepath = path.join(DATA_DIR, filename);
await writeFile(filepath, JSON.stringify(products, null, 2), "utf-8");
console.log(` Wrote ${products.length} products → ${filepath}`);
return filepath;
}

View File

@ -6,11 +6,22 @@ export interface ProductDetail {
articleNumber: string;
price: number;
discountPrice: number | null;
discountPercent: number | null;
availability: "in-stock" | "made-to-order" | "coming-soon";
frostResistance: number;
shortDescription: string;
technicalSpecs: string;
bodyText: string;
imageUrls: string[];
options: Array<{ name: string; priceModifier: number; description: string }>;
sizeOptions: string[];
directionOptions: string[];
producer: string | null;
}
function parsePrice(text: string): number {
const digits = text.replace(/[^\d]/g, "");
return digits ? parseInt(digits, 10) : 0;
}
export async function extractProduct(url: string): Promise<ProductDetail> {
@ -21,40 +32,69 @@ export async function extractProduct(url: string): Promise<ProductDetail> {
const name = $("h1").first().text().trim();
const bodyText = $("body").text();
const allPrices = [...bodyText.matchAll(/(\d[\d\s]*)\s*(?:руб|₽|РУБ)/gi)].map(
(m) => parseInt(m[1].replace(/\s/g, ""), 10),
// --- Prices from .item_price DOM ---
// <strong>71070</strong> руб. = current/discounted price
// <small style="text-decoration:line-through">99000 руб.</small> = original price
// <input type="hidden" name="cpr" value="71070"> = cart price fallback
const strongPrice = parsePrice(
$(".item_price .amount strong, .item_price strong").first().text(),
);
const smallPrice = parsePrice($(".item_price small").first().text());
const cartPrice = parsePrice(
String($("input[name=cpr]").first().val() || ""),
);
const validPrices = allPrices.filter((p) => p > 1000);
let price = validPrices[0] || 0;
let price = 0;
let discountPrice: number | null = null;
if (validPrices.length >= 2 && validPrices[1] < validPrices[0]) {
price = validPrices[0];
discountPrice = validPrices[1];
if (strongPrice && smallPrice) {
price = smallPrice;
discountPrice = strongPrice;
} else if (strongPrice) {
price = strongPrice;
} else {
price = cartPrice;
}
const availText = bodyText.toLowerCase();
const availability: ProductDetail["availability"] = availText.includes(
"в наличии",
)
? "in-stock"
: availText.includes("на заказ")
? "made-to-order"
: "in-stock";
// --- Badges from #prodimg overlay divs ---
const badgeDivs = $("#prodimg div");
let availability: ProductDetail["availability"] = "in-stock";
let discountPercent: number | null = null;
let technicalSpecs = "";
const specHeaders = $("h3, h4, strong, b").filter(
(_i, el) =>
$(el).text().toLowerCase().includes("техническое") ||
$(el).text().toLowerCase().includes("описание"),
);
if (specHeaders.length > 0) {
const specParent = specHeaders.first().parent();
technicalSpecs = specParent.text().trim().slice(0, 5000);
badgeDivs.each((_i, el) => {
const text = $(el).text().trim();
const lower = text.toLowerCase();
if (lower === "на заказ") availability = "made-to-order";
else if (lower === "скоро") availability = "coming-soon";
else if (lower === "в наличии") availability = "in-stock";
const discMatch = text.match(/^-(\d+)%$/);
if (discMatch) discountPercent = parseInt(discMatch[1], 10);
});
// --- Frost resistance from snowflake icon ---
let frostResistance = 0;
const flakeImg = $("#prodimg img[src*='flake']").first();
if (flakeImg.length) {
const m = flakeImg.attr("src")?.match(/flake(\d)/);
if (m) frostResistance = parseInt(m[1], 10);
}
// --- Size options from radio buttons ---
const sizeOptions: string[] = [];
$("input[name=size]").each((_i, el) => {
const val = $(el).attr("value");
if (val && !sizeOptions.includes(val)) sizeOptions.push(val);
});
// --- Direction (orientation) options from radio buttons ---
const directionOptions: string[] = [];
$("input[name=direction]").each((_i, el) => {
const val = $(el).attr("value");
if (val && !directionOptions.includes(val)) directionOptions.push(val);
});
// --- Images (keep existing logic) ---
const imageUrls: string[] = [];
const seenPaths = new Set<string>();
const resizePrefixRe = /^\/[fi]w?\d+(?:h\d+)?\//;
@ -65,23 +105,27 @@ export async function extractProduct(url: string): Promise<ProductDetail> {
function addImage(raw: string): void {
if (!raw) return;
if (raw.includes("logo") || raw.includes("icon") || raw.includes("banner") || raw.includes("fav")) return;
if (!raw.includes("/pages/photos/") && !raw.includes("/pages/catalog/")) return;
if (
raw.includes("logo") ||
raw.includes("icon") ||
raw.includes("banner") ||
raw.includes("fav")
)
return;
if (!raw.includes("/pages/photos/") && !raw.includes("/pages/catalog/"))
return;
const canonical = normalizeImagePath(raw);
if (seenPaths.has(canonical)) return;
seenPaths.add(canonical);
const highRes = `/iw800${canonical}`;
const fullUrl = `${BASE_URL}${highRes}`;
imageUrls.push(fullUrl);
imageUrls.push(`${BASE_URL}${highRes}`);
}
$("a[href]").each((_i, el) => {
const href = $(el).attr("href");
if (href && /\.(jpe?g|png|webp)$/i.test(href)) {
addImage(href);
}
if (href && /\.(jpe?g|png|webp)$/i.test(href)) addImage(href);
});
$("img").each((_i, el) => {
@ -89,33 +133,118 @@ export async function extractProduct(url: string): Promise<ProductDetail> {
if (src) addImage(src);
});
// --- Paid options from <ul> after "Платные опции:" heading ---
const options: ProductDetail["options"] = [];
const optionMatches = [
...bodyText.matchAll(
/([^:•\n]+?):\s*\+?\s*(\d[\d\s.]*)\s*(?:рублей|руб)/gi,
),
];
for (const match of optionMatches) {
const optName = match[1].trim();
const optPrice = parseInt(match[2].replace(/[\s.]/g, ""), 10);
if (optName.length > 3 && optName.length < 100 && optPrice > 0) {
options.push({
name: optName,
priceModifier: optPrice,
description: "",
$(".product_inf1 strong, .product_inf1 b, .product_inf1 p").each((_i, el) => {
if (options.length > 0) return false;
if (!$(el).text().includes("Платные опции")) return;
let block = $(el);
while (block.length && block.is("strong, b, em, span, a, i")) {
block = block.parent();
}
const ul = block.nextAll("ul").first();
if (!ul.length) return;
ul.find("li").each((_j, li) => {
const text = $(li).text().trim();
if (!text || text.length < 3) return;
const priceMatch = text.match(/\+\s*([\d\s.,]+)\s*рубл/i);
const percentMatch = text.match(/\+\s*(\d+)\s*%/);
let priceModifier = 0;
let description = "";
if (priceMatch) {
priceModifier = parseFloat(
priceMatch[1].replace(/\s/g, "").replace(",", "."),
);
} else if (percentMatch) {
description = `+${percentMatch[1]}%`;
}
const optName = text
.replace(/\+\s*[\d\s.,]+\s*(?:рублей|рубля|руб\.?)/gi, "")
.replace(/\+\s*\d+\s*%/g, "")
.replace(/[+:]+\s*$/, "")
.trim();
if (optName.length > 2) {
options.push({ name: optName, priceModifier, description });
}
});
});
// --- Remove script/style/noscript before text-based extractions ---
$("script, style, noscript").remove();
// --- Technical specs from #zz2 ---
let technicalSpecs = "";
const zz2 = $("#zz2");
if (zz2.length) {
technicalSpecs = zz2.text().trim().slice(0, 5000);
} else {
$("h3, h4").each((_i, el) => {
if (technicalSpecs) return false;
const t = $(el).text().toLowerCase();
if (t.includes("техническ") || t.includes("характеристик")) {
technicalSpecs = $(el).parent().text().trim().slice(0, 5000);
}
});
}
// --- Short description from "Входит в комплект" section ---
let shortDescription = "";
$("h3").each((_i, el) => {
if (shortDescription) return false;
if (!$(el).text().includes("Входит в комплект")) return;
const parts: string[] = [];
let sibling = $(el).next();
while (sibling.length && !sibling.is("h3")) {
const t = sibling.text().trim();
if (t) parts.push(t);
sibling = sibling.next();
}
shortDescription = parts.join("\n").slice(0, 3000);
});
// --- Producer from <strong> or <p> containing "Производитель" ---
let producer: string | null = null;
$(".product_inf1 strong, .product_inf1 p").each((_i, el) => {
if (producer) return false;
const text = $(el).text().trim();
if (!text.includes("Производитель")) return;
const m = text.match(/Производитель\s+(.+)/i);
if (m) {
producer = m[1].trim().replace(/\s+/g, " ").slice(0, 100) || null;
}
});
// --- Body text (cleaned, for LLM context) ---
const bodyText = $("body")
.text()
.replace(/\s+/g, " ")
.trim()
.slice(0, 15_000);
return {
name,
articleNumber,
price,
discountPrice,
discountPercent,
availability,
shortDescription: "",
frostResistance,
shortDescription,
technicalSpecs,
bodyText,
imageUrls,
options,
sizeOptions,
directionOptions,
producer,
};
}

View File

@ -0,0 +1,249 @@
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { REQUEST_DELAY_MS } from "./config.js";
import { downloadImage } from "./download-media.js";
import {
login,
findOrCreateCategory,
createProduct,
uploadMedia,
} from "./import.js";
import type {
RawProduct,
ProcessedProductFamily,
ProcessedVariant,
} from "./llm/types.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const RAW_DIR = path.resolve(__dirname, "../data/raw");
const PROCESSED_DIR = path.resolve(__dirname, "../data/processed");
const MAX_IMAGES_PER_PRODUCT = 5;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-zа-яё0-9]+/gi, "-")
.replace(/^-+|-+$/g, "")
.replace(/-+/g, "-");
}
function mergeFamily(
family: ProcessedProductFamily,
rawMap: Map<string, RawProduct>,
categoryId: string,
imageIds: string[],
) {
const { variants } = family;
const firstRaw = rawMap.get(variants[0].articleNumber);
const prices = variants.map((v) => v.price);
const minIdx = prices.indexOf(Math.min(...prices));
const cheapest = variants[minIdx];
const orientations = new Set(
variants.map((v) => v.attributes.orientation).filter(Boolean),
);
const mergedOrientation =
orientations.has("left") && orientations.has("right")
? "universal"
: (orientations.values().next().value ?? null);
const sizes = [
...new Set(
variants
.filter((v) => v.attributes.width && v.attributes.height)
.map((v) => `${v.attributes.width}x${v.attributes.height}`),
),
];
return {
name: family.baseModelName,
slug: slugify(`${family.baseModelName}-${variants[0].articleNumber}`),
articleNumber: variants[0].articleNumber,
brand: family.brand,
category: categoryId,
price: cheapest.price,
discountPrice: cheapest.discountPrice,
discountPercent: cheapest.discountPercent,
availability: variants.some((v) => v.availability === "in-stock")
? "in-stock"
: variants[0].availability,
orientation: mergedOrientation,
sizeOptions: sizes.length > 0 ? sizes : undefined,
frostResistance: Math.max(...variants.map((v) => v.frostResistance)),
color:
variants.find((v) => v.attributes.color)?.attributes.color ?? null,
material:
variants.find((v) => v.attributes.material)?.attributes.material ?? null,
glassType:
variants.find((v) => v.attributes.glassType)?.attributes.glassType ??
null,
producer:
variants.find((v) => v.attributes.producer)?.attributes.producer ?? null,
shortDescription: firstRaw?.shortDescription,
technicalSpecs: firstRaw?.technicalSpecs,
options: firstRaw?.rawOptions,
images: imageIds.length > 0 ? imageIds : undefined,
variants: variants.map((v) => ({
articleNumber: v.articleNumber,
name: v.originalName,
width: v.attributes.width,
height: v.attributes.height,
orientation: v.attributes.orientation,
color: v.attributes.color,
price: v.price,
discountPrice: v.discountPrice,
availability: v.availability,
sourceUrl: v.sourceUrl,
})),
};
}
function collectUniqueImageUrls(variants: ProcessedVariant[]): string[] {
const seen = new Set<string>();
const urls: string[] = [];
for (const v of variants) {
for (const url of v.imageUrls) {
if (!seen.has(url)) {
seen.add(url);
urls.push(url);
}
}
}
return urls;
}
async function downloadAndUploadImages(
imageUrls: string[],
familyName: string,
canonicalArticle: string,
): Promise<string[]> {
const ids: string[] = [];
const limited = imageUrls.slice(0, MAX_IMAGES_PER_PRODUCT);
for (let i = 0; i < limited.length; i++) {
const img = await downloadImage(limited[i], canonicalArticle, i);
if (img) {
const alt = `${familyName} — фото ${i + 1}`;
const mediaId = await uploadMedia(img.buffer, img.filename, img.contentType, alt);
if (mediaId) ids.push(mediaId);
}
if (i < limited.length - 1) await sleep(REQUEST_DELAY_MS);
}
return ids;
}
async function importProcessed(): Promise<void> {
console.log("=== ADVdoors — Import Processed Data ===\n");
let processedFiles: string[];
try {
processedFiles = (await readdir(PROCESSED_DIR)).filter((f) =>
f.endsWith(".json"),
);
} catch {
console.error(`No processed data at ${PROCESSED_DIR}. Run 'llm-process' first.`);
process.exit(1);
}
if (processedFiles.length === 0) {
console.error(`No JSON files in ${PROCESSED_DIR}. Run 'llm-process' first.`);
process.exit(1);
}
console.log(`Found ${processedFiles.length} processed category files`);
await login();
const stats = { categories: 0, products: 0, images: 0, skipped: 0, errors: 0 };
for (const file of processedFiles) {
const procPath = path.join(PROCESSED_DIR, file);
const rawPath = path.join(RAW_DIR, file);
console.log(`\n--- Importing: ${file} ---`);
let families: ProcessedProductFamily[];
try {
families = JSON.parse(await readFile(procPath, "utf-8"));
} catch (error) {
console.error(` Failed to read ${procPath}:`, error);
stats.errors++;
continue;
}
const rawMap = new Map<string, RawProduct>();
try {
const rawProducts: RawProduct[] = JSON.parse(
await readFile(rawPath, "utf-8"),
);
for (const rp of rawProducts) {
rawMap.set(rp.articleNumber, rp);
}
console.log(` Loaded ${rawMap.size} raw products for join`);
} catch {
console.warn(` No matching raw file at ${rawPath}, text fields will be empty`);
}
for (const family of families) {
if (family.variants.length === 0) {
console.warn(` Skipping empty family: ${family.baseModelName}`);
stats.skipped++;
continue;
}
try {
const categorySlug = slugify(family.categoryName);
const categoryId = await findOrCreateCategory(
family.categoryName,
categorySlug,
);
stats.categories++;
console.log(
`\n Family: ${family.baseModelName} (${family.variants.length} variants)`,
);
const uniqueUrls = collectUniqueImageUrls(family.variants);
const imageIds = await downloadAndUploadImages(
uniqueUrls,
family.baseModelName,
family.variants[0].articleNumber,
);
stats.images += imageIds.length;
const productData = mergeFamily(family, rawMap, categoryId, imageIds);
const result = await createProduct(productData);
if (result) {
stats.products++;
} else {
stats.errors++;
}
} catch (error) {
console.error(
` Error importing family ${family.baseModelName}:`,
error,
);
stats.errors++;
}
}
}
console.log("\n=== Import Complete ===");
console.log(`Category lookups: ${stats.categories}`);
console.log(`Products created: ${stats.products}`);
console.log(`Images uploaded: ${stats.images}`);
console.log(`Families skipped: ${stats.skipped}`);
console.log(`Errors: ${stats.errors}`);
}
importProcessed().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});

View File

@ -156,11 +156,22 @@ export async function createProduct(data: {
category?: string;
price: number;
discountPrice?: number | null;
discountPercent?: number | null;
availability: string;
frostResistance?: number;
producer?: string | null;
width?: number | null;
height?: number | null;
material?: string | null;
color?: string | null;
glassType?: string | null;
orientation?: string | null;
sizeOptions?: string[];
shortDescription?: string;
technicalSpecs?: string;
options?: Array<{ name: string; priceModifier: number; description?: string }>;
images?: string[];
variants?: unknown;
}): Promise<string | null> {
try {
const existing = await payloadRequest(
@ -173,7 +184,7 @@ export async function createProduct(data: {
return existing.docs[0].id;
}
const payload: Record<string, unknown> = {
const body: Record<string, unknown> = {
name: data.name,
slug: data.slug,
articleNumber: data.articleNumber,
@ -182,13 +193,25 @@ export async function createProduct(data: {
availability: data.availability,
};
if (data.category) payload.category = data.category;
if (data.discountPrice) payload.discountPrice = data.discountPrice;
if (data.shortDescription) payload.shortDescription = data.shortDescription;
if (data.options?.length) payload.options = data.options;
if (data.images?.length) payload.images = data.images;
if (data.category) body.category = data.category;
if (data.discountPrice) body.discountPrice = data.discountPrice;
if (data.discountPercent) body.discountPercent = data.discountPercent;
if (data.frostResistance) body.frostResistance = data.frostResistance;
if (data.producer) body.producer = data.producer;
if (data.width) body.width = data.width;
if (data.height) body.height = data.height;
if (data.material) body.material = data.material;
if (data.color) body.color = data.color;
if (data.glassType) body.glassType = data.glassType;
if (data.orientation) body.orientation = data.orientation;
if (data.sizeOptions?.length) body.sizeOptions = data.sizeOptions;
if (data.shortDescription) body.shortDescription = data.shortDescription;
if (data.technicalSpecs) body.technicalSpecs = data.technicalSpecs;
if (data.options?.length) body.options = data.options;
if (data.images?.length) body.images = data.images;
if (data.variants) body.variants = data.variants;
const created = await payloadRequest("POST", "/products", payload);
const created = await payloadRequest("POST", "/products", body);
console.log(` Created product: ${data.name}`);
return created.doc?.id || null;
} catch (error) {

View File

@ -3,6 +3,8 @@ import { crawlAllPages, type CatalogListItem } from "./crawl.js";
import { extractProduct } from "./extract.js";
import { downloadImage } from "./download-media.js";
import { login, findOrCreateCategory, createProduct, uploadMedia } from "./import.js";
import { dumpCategory } from "./dump.js";
import type { RawProduct } from "./llm/types.js";
function slugify(text: string): string {
return text
@ -23,8 +25,80 @@ function detectBrand(name: string, fallback: string | null): string {
return fallback || "ALAVUS";
}
async function main() {
console.log("=== ADVdoors Scraper ===\n");
async function scrapeRaw() {
console.log("=== ADVdoors Scraper — Raw Dump Mode ===\n");
const stats = { categories: 0, products: 0, errors: 0 };
for (const catalogPage of CATALOG_PAGES) {
console.log(`\n--- Crawling: ${catalogPage.category} (${catalogPage.url}) ---`);
let items: CatalogListItem[];
try {
items = await crawlAllPages(catalogPage.url);
} catch (error) {
console.error(` Failed to crawl ${catalogPage.url}:`, error);
stats.errors++;
continue;
}
console.log(` Found ${items.length} products on listing pages`);
const rawProducts: RawProduct[] = [];
for (const item of items) {
try {
console.log(` Extracting: ${item.name}`);
const detail = await extractProduct(item.productUrl);
rawProducts.push({
sourceUrl: item.productUrl,
categoryUrl: catalogPage.url,
categoryName: catalogPage.category,
categoryBrand: catalogPage.brand,
name: detail.name || item.name,
articleNumber: detail.articleNumber,
price: detail.price || item.price,
discountPrice: detail.discountPrice ?? item.discountPrice,
discountPercent: detail.discountPercent,
availability: detail.availability || item.availability,
frostResistance: detail.frostResistance,
shortDescription: detail.shortDescription,
technicalSpecs: detail.technicalSpecs,
bodyText: detail.bodyText,
imageUrls: detail.imageUrls,
rawOptions: detail.options.map((o) => ({
name: o.name,
priceModifier: o.priceModifier,
description: o.description,
})),
sizeOptions: detail.sizeOptions,
directionOptions: detail.directionOptions,
producer: detail.producer,
scrapedAt: new Date().toISOString(),
});
stats.products++;
} catch (error) {
console.error(` Error extracting ${item.name}:`, error);
stats.errors++;
}
}
if (rawProducts.length > 0) {
await dumpCategory(catalogPage.category, rawProducts);
stats.categories++;
}
}
console.log("\n=== Raw Scraping Complete ===");
console.log(`Categories dumped: ${stats.categories}`);
console.log(`Products scraped: ${stats.products}`);
console.log(`Errors: ${stats.errors}`);
}
async function scrapeAndImport() {
console.log("=== ADVdoors Scraper — Import Mode ===\n");
await login();
@ -91,8 +165,13 @@ async function main() {
category: categoryId,
price: detail.price || item.price,
discountPrice: detail.discountPrice || item.discountPrice,
discountPercent: detail.discountPercent,
availability: detail.availability || item.availability,
frostResistance: detail.frostResistance,
producer: detail.producer,
sizeOptions: detail.sizeOptions.length > 0 ? detail.sizeOptions : undefined,
shortDescription: detail.shortDescription,
technicalSpecs: detail.technicalSpecs,
options: detail.options,
images: imageIds.length > 0 ? imageIds : undefined,
});
@ -112,7 +191,16 @@ async function main() {
console.log(`Errors: ${stats.errors}`);
}
main().catch((error) => {
const command = process.argv[2];
if (command === "raw") {
scrapeRaw().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
} else {
scrapeAndImport().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
}

View File

@ -0,0 +1,45 @@
import OpenAI from "openai";
import { OPENROUTER_API_KEY, OPENROUTER_MODEL } from "../config.js";
let client: OpenAI | null = null;
function getClient(): OpenAI {
if (!client) {
if (!OPENROUTER_API_KEY) {
throw new Error("OPENROUTER_API_KEY is not set");
}
client = new OpenAI({
baseURL: "https://openrouter.ai/api/v1",
apiKey: OPENROUTER_API_KEY,
defaultHeaders: {
"HTTP-Referer": "https://advdoors.ru",
"X-Title": "ADVdoors Scraper",
},
});
}
return client;
}
export async function chatJSON<T>(
systemPrompt: string,
userMessage: string,
): Promise<T> {
const ai = getClient();
const response = await ai.chat.completions.create({
model: OPENROUTER_MODEL,
response_format: { type: "json_object" },
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userMessage },
],
temperature: 0.1,
});
const text = response.choices[0]?.message?.content;
if (!text) {
throw new Error("Empty response from OpenRouter");
}
return JSON.parse(text) as T;
}

View File

@ -0,0 +1,205 @@
import { readdir, readFile, mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { chatJSON } from "./openrouter.js";
import {
GROUPING_SYSTEM_PROMPT,
buildGroupingUserMessage,
EXTRACTION_SYSTEM_PROMPT,
buildExtractionUserMessage,
} from "./prompts.js";
import type {
RawProduct,
GroupingResult,
AttributeResult,
ProcessedProductFamily,
ProcessedVariant,
} from "./types.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const RAW_DIR = path.resolve(__dirname, "../../data/raw");
const PROCESSED_DIR = path.resolve(__dirname, "../../data/processed");
async function loadRawCategory(filepath: string): Promise<RawProduct[]> {
const text = await readFile(filepath, "utf-8");
return JSON.parse(text) as RawProduct[];
}
async function groupProducts(
products: RawProduct[],
): Promise<GroupingResult> {
const summaries = products.map((p) => ({
articleNumber: p.articleNumber,
name: p.name,
}));
console.log(` Grouping ${summaries.length} products...`);
return chatJSON<GroupingResult>(
GROUPING_SYSTEM_PROMPT,
buildGroupingUserMessage(summaries),
);
}
async function extractAttributes(
product: RawProduct,
): Promise<AttributeResult> {
return chatJSON<AttributeResult>(
EXTRACTION_SYSTEM_PROMPT,
buildExtractionUserMessage({
articleNumber: product.articleNumber,
name: product.name,
technicalSpecs: product.technicalSpecs,
bodyText: product.bodyText,
frostResistance: product.frostResistance,
sizeOptions: product.sizeOptions ?? [],
directionOptions: product.directionOptions ?? [],
producer: product.producer ?? null,
}),
);
}
function detectBrand(products: RawProduct[]): string {
for (const p of products) {
if (p.categoryBrand) return p.categoryBrand;
}
const first = products[0];
if (!first) return "ALAVUS";
const upper = first.name.toUpperCase();
if (upper.includes("KASKI")) return "KASKI";
if (upper.includes("ALAVUS")) return "ALAVUS";
if (upper.includes("SWEDOOR")) return "SWEDOOR";
if (upper.includes("JELD-WEN")) return "JELD-WEN";
if (upper.includes("MATTIOVI")) return "MATTIOVI";
if (upper.includes("ABLOY")) return "ABLOY";
return "ALAVUS";
}
async function processCategory(
filepath: string,
): Promise<ProcessedProductFamily[]> {
const products = await loadRawCategory(filepath);
const filename = path.basename(filepath, ".json");
console.log(`\n--- Processing: ${filename} (${products.length} products) ---`);
if (products.length === 0) return [];
const grouping = await groupProducts(products);
const productMap = new Map(products.map((p) => [p.articleNumber, p]));
const families: ProcessedProductFamily[] = [];
for (const group of grouping.groups) {
console.log(` Group: ${group.baseModelName} (${group.articles.length} variants)`);
const variants: ProcessedVariant[] = [];
for (const article of group.articles) {
const raw = productMap.get(article);
if (!raw) {
console.warn(` Article ${article} not found in raw data, skipping`);
continue;
}
try {
console.log(` Extracting attributes for ${article}...`);
const result = await extractAttributes(raw);
variants.push({
articleNumber: raw.articleNumber,
originalName: raw.name,
sourceUrl: raw.sourceUrl,
price: raw.price,
discountPrice: raw.discountPrice,
discountPercent: raw.discountPercent ?? null,
availability: raw.availability,
frostResistance: raw.frostResistance ?? 0,
imageUrls: raw.imageUrls,
attributes: result.extractedAttributes,
});
} catch (error) {
console.error(` Failed to extract attributes for ${article}:`, error);
variants.push({
articleNumber: raw.articleNumber,
originalName: raw.name,
sourceUrl: raw.sourceUrl,
price: raw.price,
discountPrice: raw.discountPrice,
discountPercent: raw.discountPercent ?? null,
availability: raw.availability,
frostResistance: raw.frostResistance ?? 0,
imageUrls: raw.imageUrls,
attributes: {
width: null,
height: null,
color: null,
colors: { ral: [], rr: [], ncs: [], ttm: [], other: [] },
orientation: null,
glassType: null,
material: null,
frostResistance: raw.frostResistance ?? 0,
sizeOptions: raw.sizeOptions ?? [],
producer: raw.producer ?? null,
},
});
}
}
const groupProducts = group.articles
.map((a) => productMap.get(a))
.filter(Boolean) as RawProduct[];
families.push({
baseModelName: group.baseModelName,
brand: detectBrand(groupProducts),
categoryName: products[0]?.categoryName ?? filename,
description: "",
variants,
});
}
return families;
}
export async function processAllCategories(): Promise<void> {
console.log("=== ADVdoors LLM Processor ===\n");
let files: string[];
try {
files = (await readdir(RAW_DIR)).filter((f) => f.endsWith(".json"));
} catch {
console.error(`No raw data found at ${RAW_DIR}. Run 'scrape-raw' first.`);
process.exit(1);
}
if (files.length === 0) {
console.error(`No JSON files in ${RAW_DIR}. Run 'scrape-raw' first.`);
process.exit(1);
}
await mkdir(PROCESSED_DIR, { recursive: true });
const stats = { files: 0, families: 0, variants: 0, errors: 0 };
for (const file of files) {
try {
const filepath = path.join(RAW_DIR, file);
const families = await processCategory(filepath);
const outPath = path.join(PROCESSED_DIR, file);
await writeFile(outPath, JSON.stringify(families, null, 2), "utf-8");
console.log(` Wrote ${families.length} families → ${outPath}`);
stats.files++;
stats.families += families.length;
stats.variants += families.reduce((s, f) => s + f.variants.length, 0);
} catch (error) {
console.error(` Error processing ${file}:`, error);
stats.errors++;
}
}
console.log("\n=== Processing Complete ===");
console.log(`Files processed: ${stats.files}`);
console.log(`Product families: ${stats.families}`);
console.log(`Total variants: ${stats.variants}`);
console.log(`Errors: ${stats.errors}`);
}

View File

@ -0,0 +1,109 @@
export const GROUPING_SYSTEM_PROMPT = `Ты — эксперт по каталогу финских дверей (KASKI, SWEDOOR/JELD-WEN, ALAVUS, MATTIOVI, ABLOY).
Тебе будет дан список товаров из одной категории каталога. Многие из них это варианты одной и той же модели двери, отличающиеся размером, цветом, ориентацией (левая/правая) или другими параметрами.
Твоя задача сгруппировать артикулы, которые относятся к одной и той же базовой модели двери.
Правила:
- Если название отличается только размером (например, 900x2100 vs 1000x2100), цветом (белый/серый/коричневый), ориентацией (лев./прав.) это одна модель.
- Если двери принципиально разные (разная конструкция, разный тип) это разные модели.
- Если не уверен лучше не объединять.
- baseModelName должно быть чистое, человекочитаемое название без размеров/цветов/ориентации.
Ответь строго в формате JSON:
{
"groups": [
{
"baseModelName": "Чистое название модели",
"articles": ["арт1", "арт2", ...]
}
]
}`;
export function buildGroupingUserMessage(
products: Array<{ articleNumber: string; name: string }>,
): string {
const lines = products.map(
(p) => `- Артикул: ${p.articleNumber} | Название: ${p.name}`,
);
return `Категория содержит ${products.length} товаров:\n\n${lines.join("\n")}`;
}
export const EXTRACTION_SYSTEM_PROMPT = `Ты — эксперт по каталогу финских дверей.
Тебе будет дана информация о товаре (дверь) со старого сайта. Извлеки структурированные атрибуты.
Извлекай:
- width: ширина в мм (число или null)
- height: высота в мм (число или null)
- color: основной цвет/покрытие (строка или null), например "белый", "RR32 тёмно-коричневый", "серый RAL 7040"
- colors: объект со списками цветовых кодов, найденных в тексте:
- ral: массив RAL-кодов (например ["RAL 7040", "RAL 7024"])
- rr: массив кодов Ruukki RR (например ["RR23", "RR32"])
- ncs: массив кодов NCS (например ["NCS S 0502-Y"])
- ttm: массив кодов TTM (например ["TTM 0965"])
- other: массив других цветовых обозначений
- orientation: "left", "right" или "universal" (или null если неизвестно)
- glassType: тип остекления (строка или null), например "стеклопакет", "без стекла", "триплекс"
- material: материал (строка или null), например "сталь/дерево", "массив сосны", "МДФ"
- frostResistance: уровень морозостойкости (число 0-3, берётся из входных данных)
- sizeOptions: массив доступных размеров (берётся из входных данных)
- producer: производитель (строка или null, берётся из входных данных если есть)
Правила:
- Размеры часто указаны в формате "900x2100" или "9x21" (в этом случае умножь на 100).
- Ориентация может быть указана как "ЛО" (левое открывание) = left, "ПО" (правое) = right. Если доступны оба направления "universal".
- Если атрибут не удаётся определить ставь null.
- НЕ выдумывай данные, только то что есть в тексте.
- Для colors ищи ВСЕ упоминания цветовых кодов (RAL, RR, NCS, TTM) по всему тексту, включая описание и опции.
- frostResistance, sizeOptions и producer бери из предоставленных данных, не меняй.
Ответь строго в формате JSON:
{
"articleNumber": "...",
"extractedAttributes": {
"width": ...,
"height": ...,
"color": ...,
"colors": { "ral": [...], "rr": [...], "ncs": [...], "ttm": [...], "other": [...] },
"orientation": ...,
"glassType": ...,
"material": ...,
"frostResistance": ...,
"sizeOptions": [...],
"producer": ...
}
}`;
export function buildExtractionUserMessage(product: {
articleNumber: string;
name: string;
technicalSpecs: string;
bodyText: string;
frostResistance: number;
sizeOptions: string[];
directionOptions: string[];
producer: string | null;
}): string {
const specs = product.technicalSpecs
? `\nТехнические характеристики:\n${product.technicalSpecs.slice(0, 3000)}`
: "";
const body = product.bodyText
? `\nТекст страницы:\n${product.bodyText.slice(0, 5000)}`
: "";
const meta = [
`Морозостойкость (снежинки): ${product.frostResistance}`,
product.sizeOptions.length > 0
? `Доступные размеры: ${product.sizeOptions.join(", ")}`
: null,
product.directionOptions.length > 0
? `Доступные направления: ${product.directionOptions.join(", ")}`
: null,
product.producer ? `Производитель: ${product.producer}` : null,
]
.filter(Boolean)
.join("\n");
return `Артикул: ${product.articleNumber}\nНазвание: ${product.name}\n${meta}${specs}${body}`;
}

View File

@ -0,0 +1,77 @@
export interface RawProduct {
sourceUrl: string;
categoryUrl: string;
categoryName: string;
categoryBrand: string | null;
name: string;
articleNumber: string;
price: number;
discountPrice: number | null;
discountPercent: number | null;
availability: string;
frostResistance: number;
shortDescription: string;
technicalSpecs: string;
bodyText: string;
imageUrls: string[];
rawOptions: Array<{ name: string; priceModifier: number; description: string }>;
sizeOptions: string[];
directionOptions: string[];
producer: string | null;
scrapedAt: string;
}
export interface GroupingResult {
groups: Array<{
baseModelName: string;
articles: string[];
}>;
}
export interface ColorCodes {
ral: string[];
rr: string[];
ncs: string[];
ttm: string[];
other: string[];
}
export interface ExtractedAttributes {
width: number | null;
height: number | null;
color: string | null;
colors: ColorCodes;
orientation: "left" | "right" | "universal" | null;
glassType: string | null;
material: string | null;
frostResistance: number;
sizeOptions: string[];
producer: string | null;
[key: string]: unknown;
}
export interface AttributeResult {
articleNumber: string;
extractedAttributes: ExtractedAttributes;
}
export interface ProcessedVariant {
articleNumber: string;
originalName: string;
sourceUrl: string;
price: number;
discountPrice: number | null;
discountPercent: number | null;
availability: string;
frostResistance: number;
imageUrls: string[];
attributes: ExtractedAttributes;
}
export interface ProcessedProductFamily {
baseModelName: string;
brand: string;
categoryName: string;
description: string;
variants: ProcessedVariant[];
}

View File

@ -0,0 +1,6 @@
import { processAllCategories } from "./llm/processor.js";
processAllCategories().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});

View File

@ -4,7 +4,7 @@ import type { Where } from "payload";
import config from "@payload-config";
import type { Metadata } from "next";
import { ProductCard } from "@/components/ProductCard";
import { BRANDS } from "@advdoors/shared";
import { BRANDS, AVAILABILITY_OPTIONS } from "@advdoors/shared";
export const dynamic = "force-dynamic";
@ -18,6 +18,8 @@ interface CatalogPageProps {
page?: string;
brand?: string;
category?: string;
availability?: string;
frost?: string;
q?: string;
}>;
}
@ -34,6 +36,15 @@ export default async function CatalogPage({ searchParams }: CatalogPageProps) {
if (params.category) {
conditions.push({ "category.slug": { equals: params.category } });
}
if (params.availability) {
conditions.push({ availability: { equals: params.availability } });
}
if (params.frost) {
const frostLevel = parseInt(params.frost, 10);
if (frostLevel > 0) {
conditions.push({ frostResistance: { greater_than_equal: frostLevel } });
}
}
if (params.q) {
conditions.push({
or: [
@ -65,6 +76,8 @@ export default async function CatalogPage({ searchParams }: CatalogPageProps) {
const next: Record<string, string> = {};
if (params.brand) next.brand = params.brand;
if (params.category) next.category = params.category;
if (params.availability) next.availability = params.availability;
if (params.frost) next.frost = params.frost;
if (params.q) next.q = params.q;
for (const [k, v] of Object.entries(overrides)) {
if (v) next[k] = v;
@ -74,6 +87,46 @@ export default async function CatalogPage({ searchParams }: CatalogPageProps) {
return `/catalog${qs ? `?${qs}` : ""}`;
}
const activeFilters: { key: string; label: string; clearKey: string }[] = [];
if (params.brand)
activeFilters.push({
key: "brand",
label: params.brand,
clearKey: "brand",
});
if (params.category) {
const catName =
categories.find((c) => c.slug === params.category)?.name ||
params.category;
activeFilters.push({
key: "category",
label: catName,
clearKey: "category",
});
}
if (params.availability) {
const avLabel =
AVAILABILITY_OPTIONS.find((a) => a.value === params.availability)
?.label || params.availability;
activeFilters.push({
key: "availability",
label: avLabel,
clearKey: "availability",
});
}
if (params.frost)
activeFilters.push({
key: "frost",
label: `Морозостойкость ≥ ${params.frost}`,
clearKey: "frost",
});
if (params.q)
activeFilters.push({
key: "q",
label: `«${params.q}»`,
clearKey: "q",
});
return (
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
{/* Breadcrumb */}
@ -97,6 +150,22 @@ export default async function CatalogPage({ searchParams }: CatalogPageProps) {
<aside className="space-y-6">
{/* Search */}
<form action="/catalog" method="GET">
{params.brand && (
<input type="hidden" name="brand" value={params.brand} />
)}
{params.category && (
<input type="hidden" name="category" value={params.category} />
)}
{params.availability && (
<input
type="hidden"
name="availability"
value={params.availability}
/>
)}
{params.frost && (
<input type="hidden" name="frost" value={params.frost} />
)}
<label className="text-sm font-semibold text-slate-900">
Поиск
</label>
@ -200,39 +269,99 @@ export default async function CatalogPage({ searchParams }: CatalogPageProps) {
))}
</ul>
</div>
{/* Availability */}
<div>
<h3 className="text-sm font-semibold text-slate-900">Наличие</h3>
<ul className="mt-2 space-y-1">
<li>
<Link
href={buildUrl({
availability: undefined,
page: undefined,
})}
className={`block rounded-md px-2 py-1.5 text-sm transition ${
!params.availability
? "bg-amber-50 font-semibold text-amber-700"
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
}`}
>
Любое
</Link>
</li>
{AVAILABILITY_OPTIONS.map((opt) => (
<li key={opt.value}>
<Link
href={buildUrl({
availability: opt.value,
page: undefined,
})}
className={`block rounded-md px-2 py-1.5 text-sm transition ${
params.availability === opt.value
? "bg-amber-50 font-semibold text-amber-700"
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
}`}
>
{opt.label}
</Link>
</li>
))}
</ul>
</div>
{/* Frost resistance */}
<div>
<h3 className="text-sm font-semibold text-slate-900">
Морозостойкость
</h3>
<ul className="mt-2 space-y-1">
<li>
<Link
href={buildUrl({ frost: undefined, page: undefined })}
className={`block rounded-md px-2 py-1.5 text-sm transition ${
!params.frost
? "bg-amber-50 font-semibold text-amber-700"
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
}`}
>
Любая
</Link>
</li>
{[1, 2, 3].map((level) => (
<li key={level}>
<Link
href={buildUrl({
frost: String(level),
page: undefined,
})}
className={`block rounded-md px-2 py-1.5 text-sm transition ${
params.frost === String(level)
? "bg-amber-50 font-semibold text-amber-700"
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
}`}
>
{"❆".repeat(level)} от {level}
</Link>
</li>
))}
</ul>
</div>
</aside>
{/* Product grid */}
<div>
{/* Active filters */}
{(params.brand || params.category || params.q) && (
{activeFilters.length > 0 && (
<div className="mb-6 flex flex-wrap items-center gap-2">
{params.brand && (
{activeFilters.map((f) => (
<Link
href={buildUrl({ brand: undefined, page: undefined })}
key={f.key}
href={buildUrl({ [f.clearKey]: undefined, page: undefined })}
className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-3 py-1 text-sm text-slate-700 hover:bg-slate-200"
>
{params.brand} ×
{f.label} ×
</Link>
)}
{params.category && (
<Link
href={buildUrl({ category: undefined, page: undefined })}
className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-3 py-1 text-sm text-slate-700 hover:bg-slate-200"
>
{categories.find((c) => c.slug === params.category)?.name ||
params.category}{" "}
×
</Link>
)}
{params.q && (
<Link
href={buildUrl({ q: undefined, page: undefined })}
className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-3 py-1 text-sm text-slate-700 hover:bg-slate-200"
>
&ldquo;{params.q}&rdquo; ×
</Link>
)}
))}
<Link
href="/catalog"
className="text-sm text-amber-600 hover:underline"
@ -264,6 +393,7 @@ export default async function CatalogPage({ searchParams }: CatalogPageProps) {
articleNumber: product.articleNumber,
price: product.price,
discountPrice: product.discountPrice,
frostResistance: product.frostResistance,
availability: product.availability,
images: product.images as unknown[],
}}

View File

@ -199,6 +199,7 @@ export default async function HomePage() {
articleNumber: product.articleNumber,
price: product.price,
discountPrice: product.discountPrice,
frostResistance: product.frostResistance,
availability: product.availability,
images: product.images as unknown[],
}}

View File

@ -6,6 +6,7 @@ import type { Metadata } from "next";
import { AddToCartButton } from "@/components/AddToCartButton";
import { ImageGallery } from "@/components/ImageGallery";
import { ProductCard } from "@/components/ProductCard";
import { FrostBadge } from "@/components/FrostBadge";
import { getImageUrl, getImageAlt } from "@/lib/media";
import { productJsonLd } from "@/lib/structured-data";
@ -33,6 +34,14 @@ export async function generateMetadata({
};
}
function formatOrientation(val: string | null | undefined): string | null {
if (!val) return null;
if (val === "left") return "Левая";
if (val === "right") return "Правая";
if (val === "universal") return "Универсальная";
return val;
}
export default async function ProductPage({ params }: ProductPageProps) {
const { slug } = await params;
const payload = await getPayload({ config });
@ -50,6 +59,13 @@ export default async function ProductPage({ params }: ProductPageProps) {
const hasDiscount =
product.discountPrice && product.discountPrice < product.price;
const displayPrice = hasDiscount ? product.discountPrice! : product.price;
const discountPct =
product.discountPercent ||
(hasDiscount
? Math.round(
((product.price - displayPrice) / product.price) * 100,
)
: 0);
const galleryImages = (product.images || [])
.map((img) => {
@ -73,6 +89,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
articleNumber: string;
price: number;
discountPrice?: number | null;
frostResistance?: number | null;
availability: string;
images?: unknown[];
}>;
@ -86,6 +103,38 @@ export default async function ProductPage({ params }: ProductPageProps) {
availability: product.availability,
shortDescription: product.shortDescription,
imageUrl: galleryImages[0]?.url,
material: product.material,
color: product.color,
producer: product.producer,
});
const sizeOptions: string[] = Array.isArray(product.sizeOptions)
? product.sizeOptions
: [];
const specs: { label: string; value: string }[] = [];
if (product.width && product.height)
specs.push({ label: "Размер", value: `${product.width} × ${product.height} мм` });
else if (product.width)
specs.push({ label: "Ширина", value: `${product.width} мм` });
else if (product.height)
specs.push({ label: "Высота", value: `${product.height} мм` });
if (product.material)
specs.push({ label: "Материал", value: product.material });
if (product.color) specs.push({ label: "Цвет", value: product.color });
if (product.glassType)
specs.push({ label: "Остекление", value: product.glassType });
if (product.orientation)
specs.push({
label: "Открывание",
value: formatOrientation(product.orientation) || product.orientation,
});
if (product.producer)
specs.push({ label: "Производитель", value: product.producer });
if (product.frostResistance && product.frostResistance > 0)
specs.push({
label: "Морозостойкость",
value: "❆".repeat(product.frostResistance) + ` (${product.frostResistance}/3)`,
});
return (
@ -124,9 +173,19 @@ export default async function ProductPage({ params }: ProductPageProps) {
{/* Product info */}
<div>
<div className="flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold sm:text-3xl">{product.name}</h1>
{product.frostResistance != null && product.frostResistance > 0 && (
<FrostBadge level={product.frostResistance} size="lg" />
)}
</div>
<p className="mt-1 text-sm text-slate-500">
Арт. {product.articleNumber}
{product.producer && (
<span className="ml-3 text-slate-400">
{product.producer}
</span>
)}
</p>
{/* Price */}
@ -139,13 +198,11 @@ export default async function ProductPage({ params }: ProductPageProps) {
<span className="text-lg text-slate-400 line-through">
{product.price.toLocaleString("ru-RU")}
</span>
{discountPct > 0 && (
<span className="rounded-full bg-red-100 px-2.5 py-0.5 text-sm font-semibold text-red-700">
-
{Math.round(
((product.price - displayPrice) / product.price) * 100,
)}
%
-{discountPct}%
</span>
)}
</>
) : (
<span className="text-3xl font-bold">
@ -155,7 +212,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
</div>
{/* Availability */}
<div className="mt-4">
<div className="mt-4 flex flex-wrap items-center gap-2">
<span
className={`inline-flex rounded-full px-3 py-1 text-sm font-medium ${
product.availability === "in-stock"
@ -173,6 +230,25 @@ export default async function ProductPage({ params }: ProductPageProps) {
</span>
</div>
{/* Size options */}
{sizeOptions.length > 0 && (
<div className="mt-5">
<h3 className="text-sm font-semibold text-slate-700">
Доступные размеры
</h3>
<div className="mt-2 flex flex-wrap gap-2">
{sizeOptions.map((size) => (
<span
key={size}
className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm text-slate-700"
>
{size}
</span>
))}
</div>
</div>
)}
{/* Add to cart */}
<AddToCartButton
productId={String(product.id)}
@ -181,10 +257,33 @@ export default async function ProductPage({ params }: ProductPageProps) {
articleNumber={product.articleNumber}
/>
{/* Quick specs table */}
{specs.length > 0 && (
<div className="mt-8">
<h2 className="text-lg font-semibold">Характеристики</h2>
<dl className="mt-3 divide-y rounded-lg border">
{specs.map((s) => (
<div
key={s.label}
className="flex items-center justify-between px-4 py-2.5"
>
<dt className="text-sm text-slate-500">{s.label}</dt>
<dd className="text-sm font-medium text-slate-900">
{s.value}
</dd>
</div>
))}
</dl>
</div>
)}
{/* Description */}
{product.shortDescription && (
<div className="mt-8 rounded-lg bg-slate-50 p-4">
<p className="text-sm leading-relaxed text-slate-700">
<h3 className="mb-2 text-sm font-semibold text-slate-900">
Входит в комплект
</h3>
<p className="whitespace-pre-line text-sm leading-relaxed text-slate-700">
{product.shortDescription}
</p>
</div>
@ -258,14 +357,12 @@ export default async function ProductPage({ params }: ProductPageProps) {
</div>
</div>
{/* Technical specs */}
{/* Technical specs (full text) */}
{product.technicalSpecs && (
<section className="mt-16 border-t pt-10">
<h2 className="text-2xl font-bold">Техническое описание</h2>
<div className="prose mt-4 max-w-none text-slate-700">
<p className="text-slate-500">
Подробное техническое описание доступно в карточке товара.
</p>
<p className="whitespace-pre-line">{product.technicalSpecs}</p>
</div>
</section>
)}

View File

@ -1,11 +1,22 @@
import type { CollectionConfig } from "payload";
import { BRANDS, AVAILABILITY_OPTIONS } from "@advdoors/shared";
import {
BRANDS,
AVAILABILITY_OPTIONS,
ORIENTATION_OPTIONS,
} from "@advdoors/shared";
export const Products: CollectionConfig = {
slug: "products",
admin: {
useAsTitle: "name",
defaultColumns: ["name", "articleNumber", "brand", "price", "availability"],
defaultColumns: [
"name",
"articleNumber",
"brand",
"price",
"availability",
"frostResistance",
],
},
access: {
read: () => true,
@ -42,6 +53,14 @@ export const Products: CollectionConfig = {
position: "sidebar",
},
},
{
name: "producer",
type: "text",
admin: {
position: "sidebar",
description: "Производитель (напр. KASKI, Jeld-Wen Suomi)",
},
},
{
name: "category",
type: "relationship",
@ -54,6 +73,8 @@ export const Products: CollectionConfig = {
relationTo: "media",
hasMany: true,
},
// --- Pricing ---
{
name: "price",
type: "number",
@ -68,6 +89,15 @@ export const Products: CollectionConfig = {
description: "Оставьте пустым если нет скидки",
},
},
{
name: "discountPercent",
type: "number",
min: 0,
max: 100,
admin: {
description: "Процент скидки (из бейджа на сайте)",
},
},
{
name: "availability",
type: "select",
@ -78,13 +108,99 @@ export const Products: CollectionConfig = {
value: a.value,
})),
},
// --- Physical specs ---
{
type: "row",
fields: [
{
name: "width",
type: "number",
min: 0,
admin: {
description: "Ширина, мм",
width: "50%",
},
},
{
name: "height",
type: "number",
min: 0,
admin: {
description: "Высота, мм",
width: "50%",
},
},
],
},
{
name: "material",
type: "text",
admin: {
description: "Напр.: массив сосны, МДФ",
},
},
{
name: "color",
type: "text",
admin: {
description: "Основной цвет (напр. белый, RR23 тёмно-серый)",
},
},
{
name: "glassType",
type: "text",
admin: {
description: "Тип остекления (напр. стеклопакет, закалённое)",
},
},
{
name: "orientation",
type: "select",
options: ORIENTATION_OPTIONS.map((o) => ({
label: o.label,
value: o.value,
})),
admin: {
description: "Сторона открывания",
},
},
{
name: "frostResistance",
type: "number",
min: 0,
max: 3,
defaultValue: 0,
admin: {
description: "Морозостойкость 03 (кол-во снежинок)",
},
},
{
name: "sizeOptions",
type: "json",
admin: {
description: "Доступные размеры (JSON массив строк)",
},
},
{
name: "variants",
type: "json",
admin: {
description: "Варианты товара (размеры/цвета/ориентации) с индивидуальными ценами",
},
},
// --- Text content ---
{
name: "shortDescription",
type: "textarea",
},
{
name: "technicalSpecs",
type: "richText",
type: "textarea",
admin: {
description: "Технические характеристики",
},
},
{
name: "options",

View File

@ -3,8 +3,18 @@ import { getPayload } from "payload";
import config from "@payload-config";
export async function Footer() {
let settings: {
phone?: string;
email?: string;
whatsapp?: string;
footerText?: string;
} = {};
try {
const payload = await getPayload({ config });
const settings = await payload.findGlobal({ slug: "site-settings" });
settings = await payload.findGlobal({ slug: "site-settings" });
} catch {
// Payload/DB unavailable — render footer without dynamic settings
}
return (
<footer className="border-t bg-slate-900 text-slate-300">

View File

@ -0,0 +1,46 @@
interface FrostBadgeProps {
level: number;
size?: "sm" | "lg";
}
export function FrostBadge({ level, size = "sm" }: FrostBadgeProps) {
if (level <= 0) return null;
const iconSize = size === "lg" ? "h-4 w-4" : "h-3 w-3";
const gap = size === "lg" ? "gap-0.5" : "gap-px";
const padding = size === "lg" ? "px-2 py-1" : "px-1.5 py-0.5";
const text = size === "lg" ? "text-xs" : "text-[10px]";
return (
<span
className={`inline-flex items-center ${gap} ${padding} rounded-full bg-sky-50 ${text} font-medium text-sky-700`}
title={`Морозостойкость ${level}/3`}
>
{Array.from({ length: level }).map((_, i) => (
<svg
key={i}
className={iconSize}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="2" x2="12" y2="22" />
<line x1="2" y1="12" x2="22" y2="12" />
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
<line x1="19.07" y1="4.93" x2="4.93" y2="19.07" />
<line x1="12" y1="2" x2="9" y2="5" />
<line x1="12" y1="2" x2="15" y2="5" />
<line x1="22" y1="12" x2="19" y2="9" />
<line x1="22" y1="12" x2="19" y2="15" />
<line x1="12" y1="22" x2="15" y2="19" />
<line x1="12" y1="22" x2="9" y2="19" />
<line x1="2" y1="12" x2="5" y2="15" />
<line x1="2" y1="12" x2="5" y2="9" />
</svg>
))}
</span>
);
}

View File

@ -4,8 +4,13 @@ import config from "@payload-config";
import { MobileMenu } from "./MobileMenu";
export async function Header() {
let settings: { phone?: string } = {};
try {
const payload = await getPayload({ config });
const settings = await payload.findGlobal({ slug: "site-settings" });
settings = await payload.findGlobal({ slug: "site-settings" });
} catch {
// Payload/DB unavailable — render header without dynamic settings
}
return (
<header className="sticky top-0 z-50 border-b bg-white/95 backdrop-blur">

View File

@ -1,6 +1,7 @@
import Link from "next/link";
import Image from "next/image";
import { getImageUrl, getImageAlt } from "@/lib/media";
import { FrostBadge } from "./FrostBadge";
interface ProductCardProps {
product: {
@ -10,6 +11,7 @@ interface ProductCardProps {
articleNumber: string;
price: number;
discountPrice?: number | null;
frostResistance?: number | null;
availability: string;
images?: unknown[];
};
@ -38,6 +40,12 @@ export function ProductCard({ product }: ProductCardProps) {
</span>
)}
{product.frostResistance != null && product.frostResistance > 0 && (
<span className="absolute left-3 top-3 z-10">
<FrostBadge level={product.frostResistance} />
</span>
)}
<div className="relative aspect-square overflow-hidden rounded-t-xl bg-slate-100">
{imageUrl ? (
<Image

View File

@ -9,10 +9,13 @@ export function productJsonLd(product: {
availability: string;
shortDescription?: string | null;
imageUrl?: string | null;
material?: string | null;
color?: string | null;
producer?: string | null;
}) {
const displayPrice = product.discountPrice || product.price;
return {
const jsonLd: Record<string, unknown> = {
"@context": "https://schema.org",
"@type": "Product",
name: product.name,
@ -33,6 +36,17 @@ export function productJsonLd(product: {
url: `${SITE_URL}/product/${product.slug}`,
},
};
if (product.material) jsonLd.material = product.material;
if (product.color) jsonLd.color = product.color;
if (product.producer) {
jsonLd.manufacturer = {
"@type": "Organization",
name: product.producer,
};
}
return jsonLd;
}
export function organizationJsonLd() {

View File

@ -139,6 +139,10 @@ export interface Product {
slug: string;
articleNumber: string;
brand: 'KASKI' | 'ALAVUS' | 'SWEDOOR' | 'JELD-WEN' | 'MATTIOVI' | 'ABLOY';
/**
* Производитель (напр. KASKI, Jeld-Wen Suomi)
*/
producer?: string | null;
category?: (number | null) | Category;
images?: (number | Media)[] | null;
price: number;
@ -146,23 +150,68 @@ export interface Product {
* Оставьте пустым если нет скидки
*/
discountPrice?: number | null;
/**
* Процент скидки (из бейджа на сайте)
*/
discountPercent?: number | null;
availability: 'in-stock' | 'made-to-order' | 'coming-soon';
/**
* Ширина, мм
*/
width?: number | null;
/**
* Высота, мм
*/
height?: number | null;
/**
* Напр.: массив сосны, МДФ
*/
material?: string | null;
/**
* Основной цвет (напр. белый, RR23 тёмно-серый)
*/
color?: string | null;
/**
* Тип остекления (напр. стеклопакет, закалённое)
*/
glassType?: string | null;
/**
* Сторона открывания
*/
orientation?: ('left' | 'right' | 'universal') | null;
/**
* Морозостойкость 03 (кол-во снежинок)
*/
frostResistance?: number | null;
/**
* Доступные размеры (JSON массив строк)
*/
sizeOptions?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
/**
* Варианты товара (размеры/цвета/ориентации) с индивидуальными ценами
*/
variants?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
shortDescription?: string | null;
technicalSpecs?: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
/**
* Технические характеристики
*/
technicalSpecs?: string | null;
options?:
| {
name: string;
@ -435,11 +484,22 @@ export interface ProductsSelect<T extends boolean = true> {
slug?: T;
articleNumber?: T;
brand?: T;
producer?: T;
category?: T;
images?: T;
price?: T;
discountPrice?: T;
discountPercent?: T;
availability?: T;
width?: T;
height?: T;
material?: T;
color?: T;
glassType?: T;
orientation?: T;
frostResistance?: T;
sizeOptions?: T;
variants?: T;
shortDescription?: T;
technicalSpecs?: T;
options?:

View File

@ -7,7 +7,7 @@ services:
POSTGRES_USER: advdoors
POSTGRES_PASSWORD: advdoors
ports:
- "5435:5432"
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:

View File

@ -41,10 +41,22 @@ dev:
up: services
pnpm --filter @advdoors/web dev
# Run scraper
# Run scraper (import directly to Payload)
scrape:
pnpm --filter @advdoors/scraper scrape
# Scrape raw data to JSON files (no Payload import)
scrape-raw:
pnpm --filter @advdoors/scraper scrape:raw
# Process raw scraped data with LLM (OpenRouter)
llm-process:
pnpm --filter @advdoors/scraper llm:process
# Import processed data into Payload CMS
import-processed:
pnpm --filter @advdoors/scraper import:processed
# Build all packages
build:
pnpm turbo build

View File

@ -1,4 +1,4 @@
import type { Brand, Availability, OrderStatus } from "./types";
import type { Brand, Availability, Orientation, OrderStatus } from "./types";
export const BRANDS: { value: Brand; label: string }[] = [
{ value: "KASKI", label: "KASKI" },
@ -15,6 +15,12 @@ export const AVAILABILITY_OPTIONS: { value: Availability; label: string }[] = [
{ value: "coming-soon", label: "Скоро в продаже" },
];
export const ORIENTATION_OPTIONS: { value: Orientation; label: string }[] = [
{ value: "left", label: "Левая" },
{ value: "right", label: "Правая" },
{ value: "universal", label: "Универсальная" },
];
export const ORDER_STATUSES: { value: OrderStatus; label: string }[] = [
{ value: "new", label: "Новый" },
{ value: "in-progress", label: "В работе" },

View File

@ -5,7 +5,17 @@ export interface ProductData {
brand: Brand;
price: number;
discountPrice?: number;
discountPercent?: number;
availability: Availability;
frostResistance?: number;
producer?: string;
width?: number;
height?: number;
material?: string;
color?: string;
glassType?: string;
orientation?: Orientation;
sizeOptions?: string[];
shortDescription?: string;
technicalSpecs?: string;
options?: ProductOption[];
@ -55,4 +65,6 @@ export type Brand =
export type Availability = "in-stock" | "made-to-order" | "coming-soon";
export type Orientation = "left" | "right" | "universal";
export type OrderStatus = "new" | "in-progress" | "completed" | "cancelled";

283
pnpm-lock.yaml generated
View File

@ -16,6 +16,9 @@ importers:
cheerio:
specifier: ^1
version: 1.2.0
openai:
specifier: ^4.104.0
version: 4.104.0(ws@8.20.0)
undici:
specifier: ^7
version: 7.24.7
@ -1783,6 +1786,12 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node-fetch@2.6.13':
resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==}
'@types/node@18.19.130':
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
'@types/node@22.19.15':
resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
@ -1882,6 +1891,10 @@ packages:
resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@ -1892,6 +1905,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
agentkeepalive@4.6.0:
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
engines: {node: '>= 8.0.0'}
ajv@6.14.0:
resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
@ -1909,6 +1926,9 @@ packages:
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
@ -1964,6 +1984,10 @@ packages:
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
engines: {node: '>=10.16.0'}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
callsites@3.1.0:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
@ -2032,6 +2056,10 @@ packages:
colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
@ -2106,6 +2134,10 @@ packages:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
@ -2236,6 +2268,10 @@ packages:
sqlite3:
optional: true
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
encoding-sniffer@0.2.1:
resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==}
@ -2261,6 +2297,22 @@ packages:
error-ex@1.3.4:
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
es-errors@1.3.0:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.1.0:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
esbuild-register@3.6.0:
resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==}
peerDependencies:
@ -2340,6 +2392,10 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
@ -2418,6 +2474,17 @@ packages:
focus-trap@7.5.4:
resolution: {integrity: sha512-N7kHdlgsO/v+iD/dMoJKtsSqs5Dz/dXZVebRgJw23LDk+jMi/974zyiOYDziY2JPp8xivq9BmUGwIJMiuSBi7w==}
form-data-encoder@1.7.2:
resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
formdata-node@4.4.1:
resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==}
engines: {node: '>= 12.20'}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -2426,6 +2493,14 @@ packages:
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
get-tsconfig@4.13.7:
resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==}
@ -2452,6 +2527,10 @@ packages:
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
engines: {node: '>=18'}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@ -2482,6 +2561,14 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
has-tostringtag@1.0.2:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
@ -2503,6 +2590,9 @@ packages:
resolution: {integrity: sha512-O5kPr7AW7wYd/BBiOezTwnVAnmSNFY+J7hlZD2X5IOxVBetjcHAiTXhzj0gMrnojQlwy+UT1/Y3H3vJ3UlmvLA==}
engines: {node: '>= 0.4.0'}
humanize-ms@1.2.1:
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
@ -2759,6 +2849,10 @@ packages:
engines: {node: '>= 18'}
hasBin: true
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
md5@2.3.0:
resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==}
@ -2859,6 +2953,14 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
@ -2904,6 +3006,20 @@ packages:
sass:
optional: true
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
deprecated: Use your platform's native DOMException instead
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
@ -2928,6 +3044,18 @@ packages:
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
openai@4.104.0:
resolution: {integrity: sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==}
hasBin: true
peerDependencies:
ws: ^8.18.0
zod: ^3.23.8
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@ -3399,6 +3527,9 @@ packages:
resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
engines: {node: '>=14.16'}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
truncate-utf8-bytes@1.0.2:
resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==}
@ -3444,6 +3575,9 @@ packages:
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
engines: {node: '>=18'}
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@ -3505,6 +3639,13 @@ packages:
vfile-message@4.0.3:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
web-streams-polyfill@4.0.0-beta.3:
resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==}
engines: {node: '>= 14'}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
whatwg-encoding@3.1.1:
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
engines: {node: '>=18'}
@ -3518,6 +3659,9 @@ packages:
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
engines: {node: '>=18'}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
which@1.3.1:
resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==}
hasBin: true
@ -5638,6 +5782,15 @@ snapshots:
'@types/ms@2.1.0': {}
'@types/node-fetch@2.6.13':
dependencies:
'@types/node': 22.19.15
form-data: 4.0.5
'@types/node@18.19.130':
dependencies:
undici-types: 5.26.5
'@types/node@22.19.15':
dependencies:
undici-types: 6.21.0
@ -5768,12 +5921,20 @@ snapshots:
'@typescript-eslint/types': 8.58.0
eslint-visitor-keys: 5.0.1
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
acorn-jsx@5.3.2(acorn@8.16.0):
dependencies:
acorn: 8.16.0
acorn@8.16.0: {}
agentkeepalive@4.6.0:
dependencies:
humanize-ms: 1.2.1
ajv@6.14.0:
dependencies:
fast-deep-equal: 3.1.3
@ -5799,6 +5960,8 @@ snapshots:
argparse@2.0.1: {}
asynckit@0.4.0: {}
atomic-sleep@1.0.0: {}
babel-plugin-macros@3.1.0:
@ -5847,6 +6010,11 @@ snapshots:
dependencies:
streamsearch: 1.1.0
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
callsites@3.1.0: {}
caniuse-lite@1.0.30001784: {}
@ -5927,6 +6095,10 @@ snapshots:
colorette@2.0.20: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
commander@2.20.3: {}
concat-map@0.0.1: {}
@ -5991,6 +6163,8 @@ snapshots:
deepmerge@4.3.1: {}
delayed-stream@1.0.0: {}
dequal@2.0.3: {}
detect-file@1.0.0: {}
@ -6042,6 +6216,12 @@ snapshots:
'@types/pg': 8.10.2
pg: 8.16.3
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
encoding-sniffer@0.2.1:
dependencies:
iconv-lite: 0.6.3
@ -6066,6 +6246,21 @@ snapshots:
dependencies:
is-arrayish: 0.2.1
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
es-set-tostringtag@2.1.0:
dependencies:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.2
esbuild-register@3.6.0(esbuild@0.25.12):
dependencies:
debug: 4.4.3
@ -6237,6 +6432,8 @@ snapshots:
esutils@2.0.3: {}
event-target-shim@5.0.1: {}
events@3.3.0: {}
expand-tilde@2.0.2:
@ -6316,11 +6513,44 @@ snapshots:
dependencies:
tabbable: 6.4.0
form-data-encoder@1.7.2: {}
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.2
mime-types: 2.1.35
formdata-node@4.4.1:
dependencies:
node-domexception: 1.0.0
web-streams-polyfill: 4.0.0-beta.3
fsevents@2.3.3:
optional: true
function-bind@1.1.2: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.1.1
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.2
math-intrinsics: 1.1.0
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
get-tsconfig@4.13.7:
dependencies:
resolve-pkg-maps: 1.0.0
@ -6353,6 +6583,8 @@ snapshots:
globals@14.0.0: {}
gopd@1.2.0: {}
graceful-fs@4.2.11: {}
graphql-http@1.22.4(graphql@16.13.2):
@ -6384,6 +6616,12 @@ snapshots:
has-flag@4.0.0: {}
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
@ -6407,6 +6645,10 @@ snapshots:
http-status@2.1.0: {}
humanize-ms@1.2.1:
dependencies:
ms: 2.1.3
iconv-lite@0.6.3:
dependencies:
safer-buffer: 2.1.2
@ -6599,6 +6841,8 @@ snapshots:
marked@14.0.0: {}
math-intrinsics@1.1.0: {}
md5@2.3.0:
dependencies:
charenc: 0.0.2
@ -6838,6 +7082,12 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.2
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.5
@ -6883,6 +7133,12 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
node-domexception@1.0.0: {}
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
normalize-path@3.0.0: {}
nth-check@2.1.1:
@ -6901,6 +7157,20 @@ snapshots:
dependencies:
wrappy: 1.0.2
openai@4.104.0(ws@8.20.0):
dependencies:
'@types/node': 18.19.130
'@types/node-fetch': 2.6.13
abort-controller: 3.0.0
agentkeepalive: 4.6.0
form-data-encoder: 1.7.2
formdata-node: 4.4.1
node-fetch: 2.7.0
optionalDependencies:
ws: 8.20.0
transitivePeerDependencies:
- encoding
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@ -7442,6 +7712,8 @@ snapshots:
'@tokenizer/token': 0.3.0
ieee754: 1.2.1
tr46@0.0.3: {}
truncate-utf8-bytes@1.0.2:
dependencies:
utf8-byte-length: 1.0.5
@ -7482,6 +7754,8 @@ snapshots:
uint8array-extras@1.5.0: {}
undici-types@5.26.5: {}
undici-types@6.21.0: {}
undici@7.24.4: {}
@ -7539,6 +7813,10 @@ snapshots:
'@types/unist': 3.0.3
unist-util-stringify-position: 4.0.0
web-streams-polyfill@4.0.0-beta.3: {}
webidl-conversions@3.0.1: {}
whatwg-encoding@3.1.1:
dependencies:
iconv-lite: 0.6.3
@ -7547,6 +7825,11 @@ snapshots:
whatwg-mimetype@4.0.0: {}
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
which@1.3.1:
dependencies:
isexe: 2.0.0