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:
parent
a240d523e1
commit
8a8cfcd0f1
3
.gitignore
vendored
3
.gitignore
vendored
@ -8,6 +8,9 @@ dist/
|
||||
*.tsbuildinfo
|
||||
out/
|
||||
|
||||
# Scraper intermediate data
|
||||
apps/scraper/data/
|
||||
|
||||
# MinIO data (dev)
|
||||
minio-data/
|
||||
|
||||
|
||||
8
apps/scraper/.env.example
Normal file
8
apps/scraper/.env.example
Normal 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
|
||||
@ -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": {
|
||||
|
||||
@ -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
29
apps/scraper/src/dump.ts
Normal 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;
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
249
apps/scraper/src/import-processed.ts
Normal file
249
apps/scraper/src/import-processed.ts
Normal 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);
|
||||
});
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
45
apps/scraper/src/llm/openrouter.ts
Normal file
45
apps/scraper/src/llm/openrouter.ts
Normal 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;
|
||||
}
|
||||
205
apps/scraper/src/llm/processor.ts
Normal file
205
apps/scraper/src/llm/processor.ts
Normal 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}`);
|
||||
}
|
||||
109
apps/scraper/src/llm/prompts.ts
Normal file
109
apps/scraper/src/llm/prompts.ts
Normal 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}`;
|
||||
}
|
||||
77
apps/scraper/src/llm/types.ts
Normal file
77
apps/scraper/src/llm/types.ts
Normal 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[];
|
||||
}
|
||||
6
apps/scraper/src/process.ts
Normal file
6
apps/scraper/src/process.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { processAllCategories } from "./llm/processor.js";
|
||||
|
||||
processAllCategories().catch((error) => {
|
||||
console.error("Fatal error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -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"
|
||||
>
|
||||
“{params.q}” ×
|
||||
</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[],
|
||||
}}
|
||||
|
||||
@ -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[],
|
||||
}}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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: "Морозостойкость 0–3 (кол-во снежинок)",
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
|
||||
@ -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">
|
||||
|
||||
46
apps/web/src/components/FrostBadge.tsx
Normal file
46
apps/web/src/components/FrostBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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;
|
||||
/**
|
||||
* Морозостойкость 0–3 (кол-во снежинок)
|
||||
*/
|
||||
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?:
|
||||
|
||||
@ -7,7 +7,7 @@ services:
|
||||
POSTGRES_USER: advdoors
|
||||
POSTGRES_PASSWORD: advdoors
|
||||
ports:
|
||||
- "5435:5432"
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
|
||||
14
justfile
14
justfile
@ -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
|
||||
|
||||
@ -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: "В работе" },
|
||||
|
||||
@ -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
283
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user