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
|
*.tsbuildinfo
|
||||||
out/
|
out/
|
||||||
|
|
||||||
|
# Scraper intermediate data
|
||||||
|
apps/scraper/data/
|
||||||
|
|
||||||
# MinIO data (dev)
|
# MinIO data (dev)
|
||||||
minio-data/
|
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,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx src/index.ts",
|
"dev": "tsx --env-file=.env src/index.ts",
|
||||||
"build": "tsc",
|
"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": {
|
"dependencies": {
|
||||||
"@advdoors/shared": "workspace:*",
|
"@advdoors/shared": "workspace:*",
|
||||||
"cheerio": "^1",
|
"cheerio": "^1",
|
||||||
|
"openai": "^4.104.0",
|
||||||
"undici": "^7"
|
"undici": "^7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -21,6 +21,10 @@ export const PAYLOAD_API_URL =
|
|||||||
process.env.PAYLOAD_API_URL || "http://localhost:3001/api";
|
process.env.PAYLOAD_API_URL || "http://localhost:3001/api";
|
||||||
|
|
||||||
export const PAYLOAD_EMAIL = process.env.PAYLOAD_EMAIL || "admin@advdoors.ru";
|
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 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;
|
articleNumber: string;
|
||||||
price: number;
|
price: number;
|
||||||
discountPrice: number | null;
|
discountPrice: number | null;
|
||||||
|
discountPercent: number | null;
|
||||||
availability: "in-stock" | "made-to-order" | "coming-soon";
|
availability: "in-stock" | "made-to-order" | "coming-soon";
|
||||||
|
frostResistance: number;
|
||||||
shortDescription: string;
|
shortDescription: string;
|
||||||
technicalSpecs: string;
|
technicalSpecs: string;
|
||||||
|
bodyText: string;
|
||||||
imageUrls: string[];
|
imageUrls: string[];
|
||||||
options: Array<{ name: string; priceModifier: number; description: 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> {
|
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 name = $("h1").first().text().trim();
|
||||||
|
|
||||||
const bodyText = $("body").text();
|
// --- Prices from .item_price DOM ---
|
||||||
const allPrices = [...bodyText.matchAll(/(\d[\d\s]*)\s*(?:руб|₽|РУБ)/gi)].map(
|
// <strong>71070</strong> руб. = current/discounted price
|
||||||
(m) => parseInt(m[1].replace(/\s/g, ""), 10),
|
// <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;
|
let discountPrice: number | null = null;
|
||||||
|
|
||||||
if (validPrices.length >= 2 && validPrices[1] < validPrices[0]) {
|
if (strongPrice && smallPrice) {
|
||||||
price = validPrices[0];
|
price = smallPrice;
|
||||||
discountPrice = validPrices[1];
|
discountPrice = strongPrice;
|
||||||
|
} else if (strongPrice) {
|
||||||
|
price = strongPrice;
|
||||||
|
} else {
|
||||||
|
price = cartPrice;
|
||||||
}
|
}
|
||||||
|
|
||||||
const availText = bodyText.toLowerCase();
|
// --- Badges from #prodimg overlay divs ---
|
||||||
const availability: ProductDetail["availability"] = availText.includes(
|
const badgeDivs = $("#prodimg div");
|
||||||
"в наличии",
|
let availability: ProductDetail["availability"] = "in-stock";
|
||||||
)
|
let discountPercent: number | null = null;
|
||||||
? "in-stock"
|
|
||||||
: availText.includes("на заказ")
|
|
||||||
? "made-to-order"
|
|
||||||
: "in-stock";
|
|
||||||
|
|
||||||
let technicalSpecs = "";
|
badgeDivs.each((_i, el) => {
|
||||||
const specHeaders = $("h3, h4, strong, b").filter(
|
const text = $(el).text().trim();
|
||||||
(_i, el) =>
|
const lower = text.toLowerCase();
|
||||||
$(el).text().toLowerCase().includes("техническое") ||
|
if (lower === "на заказ") availability = "made-to-order";
|
||||||
$(el).text().toLowerCase().includes("описание"),
|
else if (lower === "скоро") availability = "coming-soon";
|
||||||
);
|
else if (lower === "в наличии") availability = "in-stock";
|
||||||
if (specHeaders.length > 0) {
|
|
||||||
const specParent = specHeaders.first().parent();
|
const discMatch = text.match(/^-(\d+)%$/);
|
||||||
technicalSpecs = specParent.text().trim().slice(0, 5000);
|
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 imageUrls: string[] = [];
|
||||||
const seenPaths = new Set<string>();
|
const seenPaths = new Set<string>();
|
||||||
const resizePrefixRe = /^\/[fi]w?\d+(?:h\d+)?\//;
|
const resizePrefixRe = /^\/[fi]w?\d+(?:h\d+)?\//;
|
||||||
@ -65,23 +105,27 @@ export async function extractProduct(url: string): Promise<ProductDetail> {
|
|||||||
|
|
||||||
function addImage(raw: string): void {
|
function addImage(raw: string): void {
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
if (raw.includes("logo") || raw.includes("icon") || raw.includes("banner") || raw.includes("fav")) return;
|
if (
|
||||||
if (!raw.includes("/pages/photos/") && !raw.includes("/pages/catalog/")) return;
|
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);
|
const canonical = normalizeImagePath(raw);
|
||||||
if (seenPaths.has(canonical)) return;
|
if (seenPaths.has(canonical)) return;
|
||||||
seenPaths.add(canonical);
|
seenPaths.add(canonical);
|
||||||
|
|
||||||
const highRes = `/iw800${canonical}`;
|
const highRes = `/iw800${canonical}`;
|
||||||
const fullUrl = `${BASE_URL}${highRes}`;
|
imageUrls.push(`${BASE_URL}${highRes}`);
|
||||||
imageUrls.push(fullUrl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$("a[href]").each((_i, el) => {
|
$("a[href]").each((_i, el) => {
|
||||||
const href = $(el).attr("href");
|
const href = $(el).attr("href");
|
||||||
if (href && /\.(jpe?g|png|webp)$/i.test(href)) {
|
if (href && /\.(jpe?g|png|webp)$/i.test(href)) addImage(href);
|
||||||
addImage(href);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$("img").each((_i, el) => {
|
$("img").each((_i, el) => {
|
||||||
@ -89,33 +133,118 @@ export async function extractProduct(url: string): Promise<ProductDetail> {
|
|||||||
if (src) addImage(src);
|
if (src) addImage(src);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Paid options from <ul> after "Платные опции:" heading ---
|
||||||
const options: ProductDetail["options"] = [];
|
const options: ProductDetail["options"] = [];
|
||||||
const optionMatches = [
|
$(".product_inf1 strong, .product_inf1 b, .product_inf1 p").each((_i, el) => {
|
||||||
...bodyText.matchAll(
|
if (options.length > 0) return false;
|
||||||
/([^:•\n]+?):\s*\+?\s*(\d[\d\s.]*)\s*(?:рублей|руб)/gi,
|
if (!$(el).text().includes("Платные опции")) return;
|
||||||
),
|
|
||||||
];
|
let block = $(el);
|
||||||
for (const match of optionMatches) {
|
while (block.length && block.is("strong, b, em, span, a, i")) {
|
||||||
const optName = match[1].trim();
|
block = block.parent();
|
||||||
const optPrice = parseInt(match[2].replace(/[\s.]/g, ""), 10);
|
}
|
||||||
if (optName.length > 3 && optName.length < 100 && optPrice > 0) {
|
|
||||||
options.push({
|
const ul = block.nextAll("ul").first();
|
||||||
name: optName,
|
if (!ul.length) return;
|
||||||
priceModifier: optPrice,
|
|
||||||
description: "",
|
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 {
|
return {
|
||||||
name,
|
name,
|
||||||
articleNumber,
|
articleNumber,
|
||||||
price,
|
price,
|
||||||
discountPrice,
|
discountPrice,
|
||||||
|
discountPercent,
|
||||||
availability,
|
availability,
|
||||||
shortDescription: "",
|
frostResistance,
|
||||||
|
shortDescription,
|
||||||
technicalSpecs,
|
technicalSpecs,
|
||||||
|
bodyText,
|
||||||
imageUrls,
|
imageUrls,
|
||||||
options,
|
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;
|
category?: string;
|
||||||
price: number;
|
price: number;
|
||||||
discountPrice?: number | null;
|
discountPrice?: number | null;
|
||||||
|
discountPercent?: number | null;
|
||||||
availability: string;
|
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;
|
shortDescription?: string;
|
||||||
technicalSpecs?: string;
|
technicalSpecs?: string;
|
||||||
options?: Array<{ name: string; priceModifier: number; description?: string }>;
|
options?: Array<{ name: string; priceModifier: number; description?: string }>;
|
||||||
images?: string[];
|
images?: string[];
|
||||||
|
variants?: unknown;
|
||||||
}): Promise<string | null> {
|
}): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const existing = await payloadRequest(
|
const existing = await payloadRequest(
|
||||||
@ -173,7 +184,7 @@ export async function createProduct(data: {
|
|||||||
return existing.docs[0].id;
|
return existing.docs[0].id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
slug: data.slug,
|
slug: data.slug,
|
||||||
articleNumber: data.articleNumber,
|
articleNumber: data.articleNumber,
|
||||||
@ -182,13 +193,25 @@ export async function createProduct(data: {
|
|||||||
availability: data.availability,
|
availability: data.availability,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (data.category) payload.category = data.category;
|
if (data.category) body.category = data.category;
|
||||||
if (data.discountPrice) payload.discountPrice = data.discountPrice;
|
if (data.discountPrice) body.discountPrice = data.discountPrice;
|
||||||
if (data.shortDescription) payload.shortDescription = data.shortDescription;
|
if (data.discountPercent) body.discountPercent = data.discountPercent;
|
||||||
if (data.options?.length) payload.options = data.options;
|
if (data.frostResistance) body.frostResistance = data.frostResistance;
|
||||||
if (data.images?.length) payload.images = data.images;
|
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}`);
|
console.log(` Created product: ${data.name}`);
|
||||||
return created.doc?.id || null;
|
return created.doc?.id || null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import { crawlAllPages, type CatalogListItem } from "./crawl.js";
|
|||||||
import { extractProduct } from "./extract.js";
|
import { extractProduct } from "./extract.js";
|
||||||
import { downloadImage } from "./download-media.js";
|
import { downloadImage } from "./download-media.js";
|
||||||
import { login, findOrCreateCategory, createProduct, uploadMedia } from "./import.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 {
|
function slugify(text: string): string {
|
||||||
return text
|
return text
|
||||||
@ -23,8 +25,80 @@ function detectBrand(name: string, fallback: string | null): string {
|
|||||||
return fallback || "ALAVUS";
|
return fallback || "ALAVUS";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function scrapeRaw() {
|
||||||
console.log("=== ADVdoors Scraper ===\n");
|
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();
|
await login();
|
||||||
|
|
||||||
@ -91,8 +165,13 @@ async function main() {
|
|||||||
category: categoryId,
|
category: categoryId,
|
||||||
price: detail.price || item.price,
|
price: detail.price || item.price,
|
||||||
discountPrice: detail.discountPrice || item.discountPrice,
|
discountPrice: detail.discountPrice || item.discountPrice,
|
||||||
|
discountPercent: detail.discountPercent,
|
||||||
availability: detail.availability || item.availability,
|
availability: detail.availability || item.availability,
|
||||||
|
frostResistance: detail.frostResistance,
|
||||||
|
producer: detail.producer,
|
||||||
|
sizeOptions: detail.sizeOptions.length > 0 ? detail.sizeOptions : undefined,
|
||||||
shortDescription: detail.shortDescription,
|
shortDescription: detail.shortDescription,
|
||||||
|
technicalSpecs: detail.technicalSpecs,
|
||||||
options: detail.options,
|
options: detail.options,
|
||||||
images: imageIds.length > 0 ? imageIds : undefined,
|
images: imageIds.length > 0 ? imageIds : undefined,
|
||||||
});
|
});
|
||||||
@ -112,7 +191,16 @@ async function main() {
|
|||||||
console.log(`Errors: ${stats.errors}`);
|
console.log(`Errors: ${stats.errors}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error) => {
|
const command = process.argv[2];
|
||||||
|
|
||||||
|
if (command === "raw") {
|
||||||
|
scrapeRaw().catch((error) => {
|
||||||
console.error("Fatal error:", error);
|
console.error("Fatal error:", error);
|
||||||
process.exit(1);
|
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 config from "@payload-config";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { ProductCard } from "@/components/ProductCard";
|
import { ProductCard } from "@/components/ProductCard";
|
||||||
import { BRANDS } from "@advdoors/shared";
|
import { BRANDS, AVAILABILITY_OPTIONS } from "@advdoors/shared";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@ -18,6 +18,8 @@ interface CatalogPageProps {
|
|||||||
page?: string;
|
page?: string;
|
||||||
brand?: string;
|
brand?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
|
availability?: string;
|
||||||
|
frost?: string;
|
||||||
q?: string;
|
q?: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
@ -34,6 +36,15 @@ export default async function CatalogPage({ searchParams }: CatalogPageProps) {
|
|||||||
if (params.category) {
|
if (params.category) {
|
||||||
conditions.push({ "category.slug": { equals: 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) {
|
if (params.q) {
|
||||||
conditions.push({
|
conditions.push({
|
||||||
or: [
|
or: [
|
||||||
@ -65,6 +76,8 @@ export default async function CatalogPage({ searchParams }: CatalogPageProps) {
|
|||||||
const next: Record<string, string> = {};
|
const next: Record<string, string> = {};
|
||||||
if (params.brand) next.brand = params.brand;
|
if (params.brand) next.brand = params.brand;
|
||||||
if (params.category) next.category = params.category;
|
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;
|
if (params.q) next.q = params.q;
|
||||||
for (const [k, v] of Object.entries(overrides)) {
|
for (const [k, v] of Object.entries(overrides)) {
|
||||||
if (v) next[k] = v;
|
if (v) next[k] = v;
|
||||||
@ -74,6 +87,46 @@ export default async function CatalogPage({ searchParams }: CatalogPageProps) {
|
|||||||
return `/catalog${qs ? `?${qs}` : ""}`;
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
@ -97,6 +150,22 @@ export default async function CatalogPage({ searchParams }: CatalogPageProps) {
|
|||||||
<aside className="space-y-6">
|
<aside className="space-y-6">
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<form action="/catalog" method="GET">
|
<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 className="text-sm font-semibold text-slate-900">
|
||||||
Поиск
|
Поиск
|
||||||
</label>
|
</label>
|
||||||
@ -200,39 +269,99 @@ export default async function CatalogPage({ searchParams }: CatalogPageProps) {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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>
|
</aside>
|
||||||
|
|
||||||
{/* Product grid */}
|
{/* Product grid */}
|
||||||
<div>
|
<div>
|
||||||
{/* Active filters */}
|
{/* Active filters */}
|
||||||
{(params.brand || params.category || params.q) && (
|
{activeFilters.length > 0 && (
|
||||||
<div className="mb-6 flex flex-wrap items-center gap-2">
|
<div className="mb-6 flex flex-wrap items-center gap-2">
|
||||||
{params.brand && (
|
{activeFilters.map((f) => (
|
||||||
<Link
|
<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"
|
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>
|
</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
|
<Link
|
||||||
href="/catalog"
|
href="/catalog"
|
||||||
className="text-sm text-amber-600 hover:underline"
|
className="text-sm text-amber-600 hover:underline"
|
||||||
@ -264,6 +393,7 @@ export default async function CatalogPage({ searchParams }: CatalogPageProps) {
|
|||||||
articleNumber: product.articleNumber,
|
articleNumber: product.articleNumber,
|
||||||
price: product.price,
|
price: product.price,
|
||||||
discountPrice: product.discountPrice,
|
discountPrice: product.discountPrice,
|
||||||
|
frostResistance: product.frostResistance,
|
||||||
availability: product.availability,
|
availability: product.availability,
|
||||||
images: product.images as unknown[],
|
images: product.images as unknown[],
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -199,6 +199,7 @@ export default async function HomePage() {
|
|||||||
articleNumber: product.articleNumber,
|
articleNumber: product.articleNumber,
|
||||||
price: product.price,
|
price: product.price,
|
||||||
discountPrice: product.discountPrice,
|
discountPrice: product.discountPrice,
|
||||||
|
frostResistance: product.frostResistance,
|
||||||
availability: product.availability,
|
availability: product.availability,
|
||||||
images: product.images as unknown[],
|
images: product.images as unknown[],
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import type { Metadata } from "next";
|
|||||||
import { AddToCartButton } from "@/components/AddToCartButton";
|
import { AddToCartButton } from "@/components/AddToCartButton";
|
||||||
import { ImageGallery } from "@/components/ImageGallery";
|
import { ImageGallery } from "@/components/ImageGallery";
|
||||||
import { ProductCard } from "@/components/ProductCard";
|
import { ProductCard } from "@/components/ProductCard";
|
||||||
|
import { FrostBadge } from "@/components/FrostBadge";
|
||||||
import { getImageUrl, getImageAlt } from "@/lib/media";
|
import { getImageUrl, getImageAlt } from "@/lib/media";
|
||||||
import { productJsonLd } from "@/lib/structured-data";
|
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) {
|
export default async function ProductPage({ params }: ProductPageProps) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const payload = await getPayload({ config });
|
const payload = await getPayload({ config });
|
||||||
@ -50,6 +59,13 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
const hasDiscount =
|
const hasDiscount =
|
||||||
product.discountPrice && product.discountPrice < product.price;
|
product.discountPrice && product.discountPrice < product.price;
|
||||||
const displayPrice = hasDiscount ? 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 || [])
|
const galleryImages = (product.images || [])
|
||||||
.map((img) => {
|
.map((img) => {
|
||||||
@ -73,6 +89,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
articleNumber: string;
|
articleNumber: string;
|
||||||
price: number;
|
price: number;
|
||||||
discountPrice?: number | null;
|
discountPrice?: number | null;
|
||||||
|
frostResistance?: number | null;
|
||||||
availability: string;
|
availability: string;
|
||||||
images?: unknown[];
|
images?: unknown[];
|
||||||
}>;
|
}>;
|
||||||
@ -86,6 +103,38 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
availability: product.availability,
|
availability: product.availability,
|
||||||
shortDescription: product.shortDescription,
|
shortDescription: product.shortDescription,
|
||||||
imageUrl: galleryImages[0]?.url,
|
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 (
|
return (
|
||||||
@ -124,9 +173,19 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
|
|
||||||
{/* Product info */}
|
{/* Product info */}
|
||||||
<div>
|
<div>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
<h1 className="text-2xl font-bold sm:text-3xl">{product.name}</h1>
|
<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">
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
Арт. {product.articleNumber}
|
Арт. {product.articleNumber}
|
||||||
|
{product.producer && (
|
||||||
|
<span className="ml-3 text-slate-400">
|
||||||
|
{product.producer}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Price */}
|
{/* Price */}
|
||||||
@ -139,13 +198,11 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
<span className="text-lg text-slate-400 line-through">
|
<span className="text-lg text-slate-400 line-through">
|
||||||
{product.price.toLocaleString("ru-RU")} ₽
|
{product.price.toLocaleString("ru-RU")} ₽
|
||||||
</span>
|
</span>
|
||||||
|
{discountPct > 0 && (
|
||||||
<span className="rounded-full bg-red-100 px-2.5 py-0.5 text-sm font-semibold text-red-700">
|
<span className="rounded-full bg-red-100 px-2.5 py-0.5 text-sm font-semibold text-red-700">
|
||||||
-
|
-{discountPct}%
|
||||||
{Math.round(
|
|
||||||
((product.price - displayPrice) / product.price) * 100,
|
|
||||||
)}
|
|
||||||
%
|
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-3xl font-bold">
|
<span className="text-3xl font-bold">
|
||||||
@ -155,7 +212,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Availability */}
|
{/* Availability */}
|
||||||
<div className="mt-4">
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`inline-flex rounded-full px-3 py-1 text-sm font-medium ${
|
className={`inline-flex rounded-full px-3 py-1 text-sm font-medium ${
|
||||||
product.availability === "in-stock"
|
product.availability === "in-stock"
|
||||||
@ -173,6 +230,25 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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 */}
|
{/* Add to cart */}
|
||||||
<AddToCartButton
|
<AddToCartButton
|
||||||
productId={String(product.id)}
|
productId={String(product.id)}
|
||||||
@ -181,10 +257,33 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
articleNumber={product.articleNumber}
|
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 */}
|
{/* Description */}
|
||||||
{product.shortDescription && (
|
{product.shortDescription && (
|
||||||
<div className="mt-8 rounded-lg bg-slate-50 p-4">
|
<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}
|
{product.shortDescription}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -258,14 +357,12 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Technical specs */}
|
{/* Technical specs (full text) */}
|
||||||
{product.technicalSpecs && (
|
{product.technicalSpecs && (
|
||||||
<section className="mt-16 border-t pt-10">
|
<section className="mt-16 border-t pt-10">
|
||||||
<h2 className="text-2xl font-bold">Техническое описание</h2>
|
<h2 className="text-2xl font-bold">Техническое описание</h2>
|
||||||
<div className="prose mt-4 max-w-none text-slate-700">
|
<div className="prose mt-4 max-w-none text-slate-700">
|
||||||
<p className="text-slate-500">
|
<p className="whitespace-pre-line">{product.technicalSpecs}</p>
|
||||||
Подробное техническое описание доступно в карточке товара.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,11 +1,22 @@
|
|||||||
import type { CollectionConfig } from "payload";
|
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 = {
|
export const Products: CollectionConfig = {
|
||||||
slug: "products",
|
slug: "products",
|
||||||
admin: {
|
admin: {
|
||||||
useAsTitle: "name",
|
useAsTitle: "name",
|
||||||
defaultColumns: ["name", "articleNumber", "brand", "price", "availability"],
|
defaultColumns: [
|
||||||
|
"name",
|
||||||
|
"articleNumber",
|
||||||
|
"brand",
|
||||||
|
"price",
|
||||||
|
"availability",
|
||||||
|
"frostResistance",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
access: {
|
access: {
|
||||||
read: () => true,
|
read: () => true,
|
||||||
@ -42,6 +53,14 @@ export const Products: CollectionConfig = {
|
|||||||
position: "sidebar",
|
position: "sidebar",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "producer",
|
||||||
|
type: "text",
|
||||||
|
admin: {
|
||||||
|
position: "sidebar",
|
||||||
|
description: "Производитель (напр. KASKI, Jeld-Wen Suomi)",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "category",
|
name: "category",
|
||||||
type: "relationship",
|
type: "relationship",
|
||||||
@ -54,6 +73,8 @@ export const Products: CollectionConfig = {
|
|||||||
relationTo: "media",
|
relationTo: "media",
|
||||||
hasMany: true,
|
hasMany: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- Pricing ---
|
||||||
{
|
{
|
||||||
name: "price",
|
name: "price",
|
||||||
type: "number",
|
type: "number",
|
||||||
@ -68,6 +89,15 @@ export const Products: CollectionConfig = {
|
|||||||
description: "Оставьте пустым если нет скидки",
|
description: "Оставьте пустым если нет скидки",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "discountPercent",
|
||||||
|
type: "number",
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
admin: {
|
||||||
|
description: "Процент скидки (из бейджа на сайте)",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "availability",
|
name: "availability",
|
||||||
type: "select",
|
type: "select",
|
||||||
@ -78,13 +108,99 @@ export const Products: CollectionConfig = {
|
|||||||
value: a.value,
|
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",
|
name: "shortDescription",
|
||||||
type: "textarea",
|
type: "textarea",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "technicalSpecs",
|
name: "technicalSpecs",
|
||||||
type: "richText",
|
type: "textarea",
|
||||||
|
admin: {
|
||||||
|
description: "Технические характеристики",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "options",
|
name: "options",
|
||||||
|
|||||||
@ -3,8 +3,18 @@ import { getPayload } from "payload";
|
|||||||
import config from "@payload-config";
|
import config from "@payload-config";
|
||||||
|
|
||||||
export async function Footer() {
|
export async function Footer() {
|
||||||
|
let settings: {
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
whatsapp?: string;
|
||||||
|
footerText?: string;
|
||||||
|
} = {};
|
||||||
|
try {
|
||||||
const payload = await getPayload({ config });
|
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 (
|
return (
|
||||||
<footer className="border-t bg-slate-900 text-slate-300">
|
<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";
|
import { MobileMenu } from "./MobileMenu";
|
||||||
|
|
||||||
export async function Header() {
|
export async function Header() {
|
||||||
|
let settings: { phone?: string } = {};
|
||||||
|
try {
|
||||||
const payload = await getPayload({ config });
|
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 (
|
return (
|
||||||
<header className="sticky top-0 z-50 border-b bg-white/95 backdrop-blur">
|
<header className="sticky top-0 z-50 border-b bg-white/95 backdrop-blur">
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { getImageUrl, getImageAlt } from "@/lib/media";
|
import { getImageUrl, getImageAlt } from "@/lib/media";
|
||||||
|
import { FrostBadge } from "./FrostBadge";
|
||||||
|
|
||||||
interface ProductCardProps {
|
interface ProductCardProps {
|
||||||
product: {
|
product: {
|
||||||
@ -10,6 +11,7 @@ interface ProductCardProps {
|
|||||||
articleNumber: string;
|
articleNumber: string;
|
||||||
price: number;
|
price: number;
|
||||||
discountPrice?: number | null;
|
discountPrice?: number | null;
|
||||||
|
frostResistance?: number | null;
|
||||||
availability: string;
|
availability: string;
|
||||||
images?: unknown[];
|
images?: unknown[];
|
||||||
};
|
};
|
||||||
@ -38,6 +40,12 @@ export function ProductCard({ product }: ProductCardProps) {
|
|||||||
</span>
|
</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">
|
<div className="relative aspect-square overflow-hidden rounded-t-xl bg-slate-100">
|
||||||
{imageUrl ? (
|
{imageUrl ? (
|
||||||
<Image
|
<Image
|
||||||
|
|||||||
@ -9,10 +9,13 @@ export function productJsonLd(product: {
|
|||||||
availability: string;
|
availability: string;
|
||||||
shortDescription?: string | null;
|
shortDescription?: string | null;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
|
material?: string | null;
|
||||||
|
color?: string | null;
|
||||||
|
producer?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const displayPrice = product.discountPrice || product.price;
|
const displayPrice = product.discountPrice || product.price;
|
||||||
|
|
||||||
return {
|
const jsonLd: Record<string, unknown> = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Product",
|
"@type": "Product",
|
||||||
name: product.name,
|
name: product.name,
|
||||||
@ -33,6 +36,17 @@ export function productJsonLd(product: {
|
|||||||
url: `${SITE_URL}/product/${product.slug}`,
|
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() {
|
export function organizationJsonLd() {
|
||||||
|
|||||||
@ -139,6 +139,10 @@ export interface Product {
|
|||||||
slug: string;
|
slug: string;
|
||||||
articleNumber: string;
|
articleNumber: string;
|
||||||
brand: 'KASKI' | 'ALAVUS' | 'SWEDOOR' | 'JELD-WEN' | 'MATTIOVI' | 'ABLOY';
|
brand: 'KASKI' | 'ALAVUS' | 'SWEDOOR' | 'JELD-WEN' | 'MATTIOVI' | 'ABLOY';
|
||||||
|
/**
|
||||||
|
* Производитель (напр. KASKI, Jeld-Wen Suomi)
|
||||||
|
*/
|
||||||
|
producer?: string | null;
|
||||||
category?: (number | null) | Category;
|
category?: (number | null) | Category;
|
||||||
images?: (number | Media)[] | null;
|
images?: (number | Media)[] | null;
|
||||||
price: number;
|
price: number;
|
||||||
@ -146,23 +150,68 @@ export interface Product {
|
|||||||
* Оставьте пустым если нет скидки
|
* Оставьте пустым если нет скидки
|
||||||
*/
|
*/
|
||||||
discountPrice?: number | null;
|
discountPrice?: number | null;
|
||||||
|
/**
|
||||||
|
* Процент скидки (из бейджа на сайте)
|
||||||
|
*/
|
||||||
|
discountPercent?: number | null;
|
||||||
availability: 'in-stock' | 'made-to-order' | 'coming-soon';
|
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;
|
shortDescription?: string | null;
|
||||||
technicalSpecs?: {
|
/**
|
||||||
root: {
|
* Технические характеристики
|
||||||
type: string;
|
*/
|
||||||
children: {
|
technicalSpecs?: string | null;
|
||||||
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;
|
|
||||||
options?:
|
options?:
|
||||||
| {
|
| {
|
||||||
name: string;
|
name: string;
|
||||||
@ -435,11 +484,22 @@ export interface ProductsSelect<T extends boolean = true> {
|
|||||||
slug?: T;
|
slug?: T;
|
||||||
articleNumber?: T;
|
articleNumber?: T;
|
||||||
brand?: T;
|
brand?: T;
|
||||||
|
producer?: T;
|
||||||
category?: T;
|
category?: T;
|
||||||
images?: T;
|
images?: T;
|
||||||
price?: T;
|
price?: T;
|
||||||
discountPrice?: T;
|
discountPrice?: T;
|
||||||
|
discountPercent?: T;
|
||||||
availability?: T;
|
availability?: T;
|
||||||
|
width?: T;
|
||||||
|
height?: T;
|
||||||
|
material?: T;
|
||||||
|
color?: T;
|
||||||
|
glassType?: T;
|
||||||
|
orientation?: T;
|
||||||
|
frostResistance?: T;
|
||||||
|
sizeOptions?: T;
|
||||||
|
variants?: T;
|
||||||
shortDescription?: T;
|
shortDescription?: T;
|
||||||
technicalSpecs?: T;
|
technicalSpecs?: T;
|
||||||
options?:
|
options?:
|
||||||
|
|||||||
@ -7,7 +7,7 @@ services:
|
|||||||
POSTGRES_USER: advdoors
|
POSTGRES_USER: advdoors
|
||||||
POSTGRES_PASSWORD: advdoors
|
POSTGRES_PASSWORD: advdoors
|
||||||
ports:
|
ports:
|
||||||
- "5435:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
14
justfile
14
justfile
@ -41,10 +41,22 @@ dev:
|
|||||||
up: services
|
up: services
|
||||||
pnpm --filter @advdoors/web dev
|
pnpm --filter @advdoors/web dev
|
||||||
|
|
||||||
# Run scraper
|
# Run scraper (import directly to Payload)
|
||||||
scrape:
|
scrape:
|
||||||
pnpm --filter @advdoors/scraper 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 all packages
|
||||||
build:
|
build:
|
||||||
pnpm turbo 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 }[] = [
|
export const BRANDS: { value: Brand; label: string }[] = [
|
||||||
{ value: "KASKI", label: "KASKI" },
|
{ value: "KASKI", label: "KASKI" },
|
||||||
@ -15,6 +15,12 @@ export const AVAILABILITY_OPTIONS: { value: Availability; label: string }[] = [
|
|||||||
{ value: "coming-soon", label: "Скоро в продаже" },
|
{ 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 }[] = [
|
export const ORDER_STATUSES: { value: OrderStatus; label: string }[] = [
|
||||||
{ value: "new", label: "Новый" },
|
{ value: "new", label: "Новый" },
|
||||||
{ value: "in-progress", label: "В работе" },
|
{ value: "in-progress", label: "В работе" },
|
||||||
|
|||||||
@ -5,7 +5,17 @@ export interface ProductData {
|
|||||||
brand: Brand;
|
brand: Brand;
|
||||||
price: number;
|
price: number;
|
||||||
discountPrice?: number;
|
discountPrice?: number;
|
||||||
|
discountPercent?: number;
|
||||||
availability: Availability;
|
availability: Availability;
|
||||||
|
frostResistance?: number;
|
||||||
|
producer?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
material?: string;
|
||||||
|
color?: string;
|
||||||
|
glassType?: string;
|
||||||
|
orientation?: Orientation;
|
||||||
|
sizeOptions?: string[];
|
||||||
shortDescription?: string;
|
shortDescription?: string;
|
||||||
technicalSpecs?: string;
|
technicalSpecs?: string;
|
||||||
options?: ProductOption[];
|
options?: ProductOption[];
|
||||||
@ -55,4 +65,6 @@ export type Brand =
|
|||||||
|
|
||||||
export type Availability = "in-stock" | "made-to-order" | "coming-soon";
|
export type Availability = "in-stock" | "made-to-order" | "coming-soon";
|
||||||
|
|
||||||
|
export type Orientation = "left" | "right" | "universal";
|
||||||
|
|
||||||
export type OrderStatus = "new" | "in-progress" | "completed" | "cancelled";
|
export type OrderStatus = "new" | "in-progress" | "completed" | "cancelled";
|
||||||
|
|||||||
283
pnpm-lock.yaml
generated
283
pnpm-lock.yaml
generated
@ -16,6 +16,9 @@ importers:
|
|||||||
cheerio:
|
cheerio:
|
||||||
specifier: ^1
|
specifier: ^1
|
||||||
version: 1.2.0
|
version: 1.2.0
|
||||||
|
openai:
|
||||||
|
specifier: ^4.104.0
|
||||||
|
version: 4.104.0(ws@8.20.0)
|
||||||
undici:
|
undici:
|
||||||
specifier: ^7
|
specifier: ^7
|
||||||
version: 7.24.7
|
version: 7.24.7
|
||||||
@ -1783,6 +1786,12 @@ packages:
|
|||||||
'@types/ms@2.1.0':
|
'@types/ms@2.1.0':
|
||||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
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':
|
'@types/node@22.19.15':
|
||||||
resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
|
resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
|
||||||
|
|
||||||
@ -1882,6 +1891,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==}
|
resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
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:
|
acorn-jsx@5.3.2:
|
||||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -1892,6 +1905,10 @@ packages:
|
|||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
agentkeepalive@4.6.0:
|
||||||
|
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
|
||||||
|
engines: {node: '>= 8.0.0'}
|
||||||
|
|
||||||
ajv@6.14.0:
|
ajv@6.14.0:
|
||||||
resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
|
resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
|
||||||
|
|
||||||
@ -1909,6 +1926,9 @@ packages:
|
|||||||
argparse@2.0.1:
|
argparse@2.0.1:
|
||||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
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:
|
atomic-sleep@1.0.0:
|
||||||
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||||
engines: {node: '>=8.0.0'}
|
engines: {node: '>=8.0.0'}
|
||||||
@ -1964,6 +1984,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
|
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
|
||||||
engines: {node: '>=10.16.0'}
|
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:
|
callsites@3.1.0:
|
||||||
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@ -2032,6 +2056,10 @@ packages:
|
|||||||
colorette@2.0.20:
|
colorette@2.0.20:
|
||||||
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
|
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:
|
commander@2.20.3:
|
||||||
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
|
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==}
|
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
delayed-stream@1.0.0:
|
||||||
|
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||||
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
||||||
dequal@2.0.3:
|
dequal@2.0.3:
|
||||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@ -2236,6 +2268,10 @@ packages:
|
|||||||
sqlite3:
|
sqlite3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
dunder-proto@1.0.1:
|
||||||
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
encoding-sniffer@0.2.1:
|
encoding-sniffer@0.2.1:
|
||||||
resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==}
|
resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==}
|
||||||
|
|
||||||
@ -2261,6 +2297,22 @@ packages:
|
|||||||
error-ex@1.3.4:
|
error-ex@1.3.4:
|
||||||
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
|
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:
|
esbuild-register@3.6.0:
|
||||||
resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==}
|
resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -2340,6 +2392,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||||
engines: {node: '>=0.10.0'}
|
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:
|
events@3.3.0:
|
||||||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||||
engines: {node: '>=0.8.x'}
|
engines: {node: '>=0.8.x'}
|
||||||
@ -2418,6 +2474,17 @@ packages:
|
|||||||
focus-trap@7.5.4:
|
focus-trap@7.5.4:
|
||||||
resolution: {integrity: sha512-N7kHdlgsO/v+iD/dMoJKtsSqs5Dz/dXZVebRgJw23LDk+jMi/974zyiOYDziY2JPp8xivq9BmUGwIJMiuSBi7w==}
|
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:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
@ -2426,6 +2493,14 @@ packages:
|
|||||||
function-bind@1.1.2:
|
function-bind@1.1.2:
|
||||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
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:
|
get-tsconfig@4.13.7:
|
||||||
resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==}
|
resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==}
|
||||||
|
|
||||||
@ -2452,6 +2527,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
|
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
gopd@1.2.0:
|
||||||
|
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
graceful-fs@4.2.11:
|
graceful-fs@4.2.11:
|
||||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||||
|
|
||||||
@ -2482,6 +2561,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||||
engines: {node: '>=8'}
|
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:
|
hasown@2.0.2:
|
||||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@ -2503,6 +2590,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-O5kPr7AW7wYd/BBiOezTwnVAnmSNFY+J7hlZD2X5IOxVBetjcHAiTXhzj0gMrnojQlwy+UT1/Y3H3vJ3UlmvLA==}
|
resolution: {integrity: sha512-O5kPr7AW7wYd/BBiOezTwnVAnmSNFY+J7hlZD2X5IOxVBetjcHAiTXhzj0gMrnojQlwy+UT1/Y3H3vJ3UlmvLA==}
|
||||||
engines: {node: '>= 0.4.0'}
|
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:
|
iconv-lite@0.6.3:
|
||||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@ -2759,6 +2849,10 @@ packages:
|
|||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
math-intrinsics@1.1.0:
|
||||||
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
md5@2.3.0:
|
md5@2.3.0:
|
||||||
resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==}
|
resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==}
|
||||||
|
|
||||||
@ -2859,6 +2953,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||||
engines: {node: '>=8.6'}
|
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:
|
minimatch@10.2.5:
|
||||||
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
|
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
|
||||||
engines: {node: 18 || 20 || >=22}
|
engines: {node: 18 || 20 || >=22}
|
||||||
@ -2904,6 +3006,20 @@ packages:
|
|||||||
sass:
|
sass:
|
||||||
optional: true
|
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:
|
normalize-path@3.0.0:
|
||||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@ -2928,6 +3044,18 @@ packages:
|
|||||||
once@1.4.0:
|
once@1.4.0:
|
||||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
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:
|
optionator@0.9.4:
|
||||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@ -3399,6 +3527,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
|
resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
|
||||||
engines: {node: '>=14.16'}
|
engines: {node: '>=14.16'}
|
||||||
|
|
||||||
|
tr46@0.0.3:
|
||||||
|
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
||||||
|
|
||||||
truncate-utf8-bytes@1.0.2:
|
truncate-utf8-bytes@1.0.2:
|
||||||
resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==}
|
resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==}
|
||||||
|
|
||||||
@ -3444,6 +3575,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
|
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
undici-types@5.26.5:
|
||||||
|
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
|
||||||
|
|
||||||
undici-types@6.21.0:
|
undici-types@6.21.0:
|
||||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||||
|
|
||||||
@ -3505,6 +3639,13 @@ packages:
|
|||||||
vfile-message@4.0.3:
|
vfile-message@4.0.3:
|
||||||
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
|
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:
|
whatwg-encoding@3.1.1:
|
||||||
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
|
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@ -3518,6 +3659,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
|
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
whatwg-url@5.0.0:
|
||||||
|
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
||||||
|
|
||||||
which@1.3.1:
|
which@1.3.1:
|
||||||
resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==}
|
resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@ -5638,6 +5782,15 @@ snapshots:
|
|||||||
|
|
||||||
'@types/ms@2.1.0': {}
|
'@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':
|
'@types/node@22.19.15':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
@ -5768,12 +5921,20 @@ snapshots:
|
|||||||
'@typescript-eslint/types': 8.58.0
|
'@typescript-eslint/types': 8.58.0
|
||||||
eslint-visitor-keys: 5.0.1
|
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):
|
acorn-jsx@5.3.2(acorn@8.16.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.16.0
|
acorn: 8.16.0
|
||||||
|
|
||||||
acorn@8.16.0: {}
|
acorn@8.16.0: {}
|
||||||
|
|
||||||
|
agentkeepalive@4.6.0:
|
||||||
|
dependencies:
|
||||||
|
humanize-ms: 1.2.1
|
||||||
|
|
||||||
ajv@6.14.0:
|
ajv@6.14.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-deep-equal: 3.1.3
|
fast-deep-equal: 3.1.3
|
||||||
@ -5799,6 +5960,8 @@ snapshots:
|
|||||||
|
|
||||||
argparse@2.0.1: {}
|
argparse@2.0.1: {}
|
||||||
|
|
||||||
|
asynckit@0.4.0: {}
|
||||||
|
|
||||||
atomic-sleep@1.0.0: {}
|
atomic-sleep@1.0.0: {}
|
||||||
|
|
||||||
babel-plugin-macros@3.1.0:
|
babel-plugin-macros@3.1.0:
|
||||||
@ -5847,6 +6010,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
streamsearch: 1.1.0
|
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: {}
|
callsites@3.1.0: {}
|
||||||
|
|
||||||
caniuse-lite@1.0.30001784: {}
|
caniuse-lite@1.0.30001784: {}
|
||||||
@ -5927,6 +6095,10 @@ snapshots:
|
|||||||
|
|
||||||
colorette@2.0.20: {}
|
colorette@2.0.20: {}
|
||||||
|
|
||||||
|
combined-stream@1.0.8:
|
||||||
|
dependencies:
|
||||||
|
delayed-stream: 1.0.0
|
||||||
|
|
||||||
commander@2.20.3: {}
|
commander@2.20.3: {}
|
||||||
|
|
||||||
concat-map@0.0.1: {}
|
concat-map@0.0.1: {}
|
||||||
@ -5991,6 +6163,8 @@ snapshots:
|
|||||||
|
|
||||||
deepmerge@4.3.1: {}
|
deepmerge@4.3.1: {}
|
||||||
|
|
||||||
|
delayed-stream@1.0.0: {}
|
||||||
|
|
||||||
dequal@2.0.3: {}
|
dequal@2.0.3: {}
|
||||||
|
|
||||||
detect-file@1.0.0: {}
|
detect-file@1.0.0: {}
|
||||||
@ -6042,6 +6216,12 @@ snapshots:
|
|||||||
'@types/pg': 8.10.2
|
'@types/pg': 8.10.2
|
||||||
pg: 8.16.3
|
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:
|
encoding-sniffer@0.2.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
iconv-lite: 0.6.3
|
iconv-lite: 0.6.3
|
||||||
@ -6066,6 +6246,21 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-arrayish: 0.2.1
|
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):
|
esbuild-register@3.6.0(esbuild@0.25.12):
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
@ -6237,6 +6432,8 @@ snapshots:
|
|||||||
|
|
||||||
esutils@2.0.3: {}
|
esutils@2.0.3: {}
|
||||||
|
|
||||||
|
event-target-shim@5.0.1: {}
|
||||||
|
|
||||||
events@3.3.0: {}
|
events@3.3.0: {}
|
||||||
|
|
||||||
expand-tilde@2.0.2:
|
expand-tilde@2.0.2:
|
||||||
@ -6316,11 +6513,44 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
tabbable: 6.4.0
|
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:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
function-bind@1.1.2: {}
|
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:
|
get-tsconfig@4.13.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
resolve-pkg-maps: 1.0.0
|
resolve-pkg-maps: 1.0.0
|
||||||
@ -6353,6 +6583,8 @@ snapshots:
|
|||||||
|
|
||||||
globals@14.0.0: {}
|
globals@14.0.0: {}
|
||||||
|
|
||||||
|
gopd@1.2.0: {}
|
||||||
|
|
||||||
graceful-fs@4.2.11: {}
|
graceful-fs@4.2.11: {}
|
||||||
|
|
||||||
graphql-http@1.22.4(graphql@16.13.2):
|
graphql-http@1.22.4(graphql@16.13.2):
|
||||||
@ -6384,6 +6616,12 @@ snapshots:
|
|||||||
|
|
||||||
has-flag@4.0.0: {}
|
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:
|
hasown@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
function-bind: 1.1.2
|
function-bind: 1.1.2
|
||||||
@ -6407,6 +6645,10 @@ snapshots:
|
|||||||
|
|
||||||
http-status@2.1.0: {}
|
http-status@2.1.0: {}
|
||||||
|
|
||||||
|
humanize-ms@1.2.1:
|
||||||
|
dependencies:
|
||||||
|
ms: 2.1.3
|
||||||
|
|
||||||
iconv-lite@0.6.3:
|
iconv-lite@0.6.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
@ -6599,6 +6841,8 @@ snapshots:
|
|||||||
|
|
||||||
marked@14.0.0: {}
|
marked@14.0.0: {}
|
||||||
|
|
||||||
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
md5@2.3.0:
|
md5@2.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
charenc: 0.0.2
|
charenc: 0.0.2
|
||||||
@ -6838,6 +7082,12 @@ snapshots:
|
|||||||
braces: 3.0.3
|
braces: 3.0.3
|
||||||
picomatch: 2.3.2
|
picomatch: 2.3.2
|
||||||
|
|
||||||
|
mime-db@1.52.0: {}
|
||||||
|
|
||||||
|
mime-types@2.1.35:
|
||||||
|
dependencies:
|
||||||
|
mime-db: 1.52.0
|
||||||
|
|
||||||
minimatch@10.2.5:
|
minimatch@10.2.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 5.0.5
|
brace-expansion: 5.0.5
|
||||||
@ -6883,6 +7133,12 @@ snapshots:
|
|||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
- babel-plugin-macros
|
- babel-plugin-macros
|
||||||
|
|
||||||
|
node-domexception@1.0.0: {}
|
||||||
|
|
||||||
|
node-fetch@2.7.0:
|
||||||
|
dependencies:
|
||||||
|
whatwg-url: 5.0.0
|
||||||
|
|
||||||
normalize-path@3.0.0: {}
|
normalize-path@3.0.0: {}
|
||||||
|
|
||||||
nth-check@2.1.1:
|
nth-check@2.1.1:
|
||||||
@ -6901,6 +7157,20 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
wrappy: 1.0.2
|
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:
|
optionator@0.9.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
deep-is: 0.1.4
|
deep-is: 0.1.4
|
||||||
@ -7442,6 +7712,8 @@ snapshots:
|
|||||||
'@tokenizer/token': 0.3.0
|
'@tokenizer/token': 0.3.0
|
||||||
ieee754: 1.2.1
|
ieee754: 1.2.1
|
||||||
|
|
||||||
|
tr46@0.0.3: {}
|
||||||
|
|
||||||
truncate-utf8-bytes@1.0.2:
|
truncate-utf8-bytes@1.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
utf8-byte-length: 1.0.5
|
utf8-byte-length: 1.0.5
|
||||||
@ -7482,6 +7754,8 @@ snapshots:
|
|||||||
|
|
||||||
uint8array-extras@1.5.0: {}
|
uint8array-extras@1.5.0: {}
|
||||||
|
|
||||||
|
undici-types@5.26.5: {}
|
||||||
|
|
||||||
undici-types@6.21.0: {}
|
undici-types@6.21.0: {}
|
||||||
|
|
||||||
undici@7.24.4: {}
|
undici@7.24.4: {}
|
||||||
@ -7539,6 +7813,10 @@ snapshots:
|
|||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
unist-util-stringify-position: 4.0.0
|
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:
|
whatwg-encoding@3.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
iconv-lite: 0.6.3
|
iconv-lite: 0.6.3
|
||||||
@ -7547,6 +7825,11 @@ snapshots:
|
|||||||
|
|
||||||
whatwg-mimetype@4.0.0: {}
|
whatwg-mimetype@4.0.0: {}
|
||||||
|
|
||||||
|
whatwg-url@5.0.0:
|
||||||
|
dependencies:
|
||||||
|
tr46: 0.0.3
|
||||||
|
webidl-conversions: 3.0.1
|
||||||
|
|
||||||
which@1.3.1:
|
which@1.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user