graphic remake as well as parcer rework

This commit is contained in:
54CHA
2025-08-09 07:34:49 +03:00
parent 4153e2c00a
commit b8e94c72cf
15 changed files with 6082 additions and 316 deletions

34
package-lock.json generated
View File

@ -8,18 +8,20 @@
"name": "openparsersferav",
"version": "0.1.0",
"dependencies": {
"@prisma/client": "^5.9.0",
"@prisma/client": "^5.22.0",
"@tailwindcss/postcss": "^4.1.7",
"anychart": "^8.13.1",
"autoprefixer": "^10.4.21",
"chart.js": "^4.5.0",
"cheerio": "^1.0.0-rc.12",
"framer-motion": "^12.12.1",
"lucide-react": "^0.525.0",
"next": "15.3.0",
"postcss": "^8.5.3",
"prisma": "^5.9.0",
"prisma": "^5.22.0",
"puppeteer": "^24.8.2",
"react": "19.1.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "19.1.0",
"react-icons": "^5.5.0",
"recharts": "^2.15.3",
@ -660,6 +662,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.10.tgz",
@ -2521,6 +2529,18 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chart.js": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/cheerio": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz",
@ -6320,6 +6340,16 @@
"node": ">=0.10.0"
}
},
"node_modules/react-chartjs-2": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.0.tgz",
"integrity": "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw==",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-dom": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",

View File

@ -14,6 +14,7 @@
"@tailwindcss/postcss": "^4.1.7",
"anychart": "^8.13.1",
"autoprefixer": "^10.4.21",
"chart.js": "^4.5.0",
"cheerio": "^1.0.0-rc.12",
"framer-motion": "^12.12.1",
"lucide-react": "^0.525.0",
@ -22,6 +23,7 @@
"prisma": "^5.9.0",
"puppeteer": "^24.8.2",
"react": "19.1.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "19.1.0",
"react-icons": "^5.5.0",
"recharts": "^2.15.3",

View File

@ -6,7 +6,6 @@
generator client {
provider = "prisma-client-js"
output = "../src/generated/prisma"
}
datasource db {

View File

@ -148,7 +148,7 @@ const config = {
"value": "prisma-client-js"
},
"output": {
"value": "/Users/alexander/qa/scan-sphere/src/generated/prisma",
"value": "/Users/alexander/qa/scan-sphere/scan-sphera-main/src/generated/prisma",
"fromEnvVar": null
},
"config": {
@ -162,7 +162,7 @@ const config = {
}
],
"previewFeatures": [],
"sourceFilePath": "/Users/alexander/qa/scan-sphere/prisma/schema.prisma",
"sourceFilePath": "/Users/alexander/qa/scan-sphere/scan-sphera-main/prisma/schema.prisma",
"isCustomOutput": true
},
"relativeEnvPaths": {
@ -176,6 +176,7 @@ const config = {
"db"
],
"activeProvider": "postgresql",
"postinstall": true,
"inlineDatasources": {
"db": {
"url": {

View File

@ -149,7 +149,7 @@ const config = {
"value": "prisma-client-js"
},
"output": {
"value": "/Users/alexander/qa/scan-sphere/src/generated/prisma",
"value": "/Users/alexander/qa/scan-sphere/scan-sphera-main/src/generated/prisma",
"fromEnvVar": null
},
"config": {
@ -163,7 +163,7 @@ const config = {
}
],
"previewFeatures": [],
"sourceFilePath": "/Users/alexander/qa/scan-sphere/prisma/schema.prisma",
"sourceFilePath": "/Users/alexander/qa/scan-sphere/scan-sphera-main/prisma/schema.prisma",
"isCustomOutput": true
},
"relativeEnvPaths": {
@ -177,6 +177,7 @@ const config = {
"db"
],
"activeProvider": "postgresql",
"postinstall": true,
"inlineDatasources": {
"db": {
"url": {

View File

@ -172,8 +172,8 @@ export async function POST(req: NextRequest) {
.filter(p => p.product.article === myArticleId)
.map(p => {
// Вычисляем позицию на странице (примерно 30 товаров на страницу)
const page = p.page || Math.ceil((p.position || 1) / 30);
const positionOnPage = p.position ? ((p.position - 1) % 30) + 1 : null;
const page = p.page || Math.ceil((p.position || 1) / 100);
const positionOnPage = p.position ? ((p.position - 1) % 100) + 1 : null;
return {
city: p.city,
@ -188,8 +188,8 @@ export async function POST(req: NextRequest) {
positions[competitorArticleId] = existingQuery.positions
.filter(p => p.product.article === competitorArticleId)
.map(p => {
const page = p.page || Math.ceil((p.position || 1) / 30);
const positionOnPage = p.position ? ((p.position - 1) % 30) + 1 : null;
const page = p.page || Math.ceil((p.position || 1) / 100);
const positionOnPage = p.position ? ((p.position - 1) % 100) + 1 : null;
return {
city: p.city,
@ -278,7 +278,7 @@ export async function POST(req: NextRequest) {
const position = posData.position;
if (position && position > 0) {
const page = Math.ceil(position / 30);
const page = Math.ceil(position / 100);
await prisma.position.create({
data: {

View File

@ -21,6 +21,12 @@ interface ParseResult {
positions: { [articleId: string]: CityPosition[] };
}
// Результат поиска позиций + данные карточек из листинга
interface SearchPositionsResult {
positions: { [articleId: string]: number | null };
listingDetails: { [articleId: string]: Partial<ProductData> & { position?: number | null } };
}
const cities = [
{ name: 'Москва', code: 'msk' },
{ name: 'Санкт-Петербург', code: 'spb' },
@ -124,6 +130,12 @@ async function createStealthBrowser() {
const page = await browser.newPage();
// Повышаем таймауты: WB может показывать антибот-экран
try {
page.setDefaultNavigationTimeout(90000);
page.setDefaultTimeout(90000);
} catch {}
// Устанавливаем случайный User-Agent
const userAgents = [
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
@ -207,11 +219,11 @@ async function robustNavigation(page: any, url: string, maxRetries: number = 3):
await page.goto(url, {
waitUntil: 'domcontentloaded',
timeout: 45000
timeout: 60000
});
// Дополнительное ожидание для полной загрузки
await randomDelay(2000, 4000);
await randomDelay(2500, 6000);
// Проверяем, что страница загрузилась корректно
const currentUrl = page.url();
@ -228,6 +240,24 @@ async function robustNavigation(page: any, url: string, maxRetries: number = 3):
}
return false;
}
// Частый антибот-экран «Почти готово...»/Cloudflare
if (
title.includes('Почти готово') ||
title.toLowerCase().includes('just a moment') ||
title.toLowerCase().includes('checking your browser')
) {
console.log(' ⏳ Обнаружен антибот-экран, ждём появления списка товаров...');
try {
await page.waitForFunction(() => {
const selectors = ['[data-nm-id]', '.product-card', '.j-card-item', '.goods-tile'];
return selectors.some(s => document.querySelector(s));
}, { timeout: 60000 });
console.log(' ✅ Список товаров появился после антибот-экрана');
} catch {
console.log(' ⚠️ Список товаров не появился в отведённое время');
}
}
return true;
@ -235,7 +265,7 @@ async function robustNavigation(page: any, url: string, maxRetries: number = 3):
console.log(` ⚠️ Ошибка навигации попытка ${attempt}:`, (error as Error).message);
if (attempt < maxRetries) {
const delay = attempt * 3000 + Math.random() * 2000; // Увеличивающаяся задержка
const delay = attempt * 4000 + Math.random() * 3000; // Увеличивающаяся задержка
console.log(` ⏳ Ожидание ${Math.round(delay/1000)}с перед следующей попыткой...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
@ -540,18 +570,13 @@ async function searchPositions(
cityCode: string,
enhancedScraping: boolean = false,
maxItems: number = 2000
): Promise<{ [articleId: string]: number | null }> {
): Promise<SearchPositionsResult> {
console.log(`Парсинг результатов поиска для города ${cityCode === 'msk' ? 'Москва' : cityCode.toUpperCase()} (${cityCode})...`);
// Сначала пробуем альтернативные API методы (более надежные)
// Получаем предварительные позиции через API как фолбэк
const apiPositions = await tryAlternativeSearch(query, targetArticles);
const foundPositions = Object.values(apiPositions).filter(pos => pos !== null);
if (foundPositions.length > 0) {
console.log(`✅ Артикулы найдены через API, пропускаем браузерный парсинг`);
return apiPositions;
}
console.log('⚠️ API поиск не дал результатов, переходим к браузерному парсингу...');
const apiFound = Object.values(apiPositions).filter(Boolean).length;
console.log(` Предварительные позиции API: ${apiFound}/${targetArticles.length}. Переходим к каталогу для точного ранка...`);
let browser, page;
@ -571,20 +596,7 @@ async function searchPositions(
}
// Устанавливаем обработчики для мониторинга AJAX-запросов (опционально)
try {
await page.setRequestInterception(true);
page.on('request', (request: any) => {
// Логируем важные запросы
if (request.url().includes('search') || request.url().includes('catalog')) {
console.log(` 🌐 AJAX запрос: ${request.url().substring(0, 100)}...`);
}
request.continue();
});
console.log('✅ Мониторинг AJAX-запросов включен');
} catch (interceptError) {
console.log('⚠️ Мониторинг AJAX-запросов недоступен:', (interceptError as Error).message);
// Продолжаем работу без мониторинга запросов
}
// Откажемся от перехвата запросов: иногда ломает навигацию
// Проверяем, что мы на правильной странице
const currentUrl = page.url();
@ -704,15 +716,22 @@ async function searchPositions(
console.log(`${hasCatalogLinks ? '✅' : '❌'} Ссылки /catalog/ найдены: ${hasCatalogLinks}`);
console.log(`${hasSearchQuery ? '✅' : '❌'} Поисковый запрос в HTML: ${hasSearchQuery}`);
return positions;
// Извлекаем данные карточек из уже загруженного листинга для целевых артикулов
const listingDetails = await extractListingDataForArticlesOnPage(page, targetArticles);
return { positions, listingDetails };
} catch (error) {
console.error('Ошибка при расширенном парсинге позиций:', error);
const emptyResult: { [articleId: string]: number | null } = {};
targetArticles.forEach(article => {
emptyResult[article] = null;
});
return emptyResult;
// Возвращаем хотя бы API-позиции, если они были
if (apiFound > 0) {
console.log('↩️ Возвращаем предварительные позиции из API из-за ошибки браузерного парсинга.');
const listingDetails: { [articleId: string]: Partial<ProductData> } = {};
return { positions: apiPositions, listingDetails };
}
const emptyPositions: { [articleId: string]: number | null } = {};
targetArticles.forEach(article => emptyPositions[article] = null);
return { positions: emptyPositions, listingDetails: {} };
} finally {
if (browser) {
await browser.close();
@ -721,77 +740,455 @@ async function searchPositions(
}
// Simplified product data function like the old version
// API-based product data function with improved data extraction
async function getProductDataViaAPI(article: string): Promise<ProductData | null> {
console.log(`🔗 Попытка получить данные товара ${article} через API...`);
try {
// Multiple API endpoints to try (from the old working version)
const apiEndpoints = [
`https://card.wb.ru/cards/v1/detail?appType=1&curr=rub&dest=-1257786&spp=30&nm=${article}`,
`https://card.wb.ru/cards/v2/detail?appType=1&curr=rub&dest=-1257786&spp=30&nm=${article}`,
`https://wbx-content-v2.wbstatic.net/ru/${article}.json`,
`https://basket-01.wb.ru/vol${Math.floor(parseInt(article) / 100000)}/part${Math.floor(parseInt(article) / 1000)}/${article}/info/ru/card.json`,
`https://product-order-qnt.wildberries.ru/by-nm/?nm=${article}`
];
for (const endpoint of apiEndpoints) {
try {
console.log(` 🌐 Пробуем API endpoint: ${endpoint.substring(0, 60)}...`);
const response = await fetch(endpoint, {
headers: {
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'ru-RU,ru;q=0.9',
'Referer': 'https://www.wildberries.ru/',
'Origin': 'https://www.wildberries.ru'
}
});
if (response.ok) {
const data = await response.json();
console.log(` 📊 API ответ получен для товара ${article}`);
// Extract data from different API structures
let productInfo = null;
if (data?.data?.products && Array.isArray(data.data.products)) {
productInfo = data.data.products[0];
} else if (data?.products && Array.isArray(data.products)) {
productInfo = data.products[0];
} else if (data?.data) {
productInfo = data.data;
} else {
productInfo = data;
}
if (productInfo && (productInfo.name || productInfo.title || productInfo.goods_name)) {
let name = productInfo.name || productInfo.title || productInfo.goods_name || `Товар ${article}`;
// Clean the name from HTML entities and extra spaces
name = name.replace(/&quot;/g, '"').replace(/&amp;/g, '&').replace(/\s+/g, ' ').trim();
const brand = productInfo.brand || productInfo.trademark || productInfo.supplier || 'Unknown Brand';
// Handle different price formats
let price = 0;
// Try to extract price from nested structures first
if (productInfo.sizes && productInfo.sizes[0]) {
const sizeInfo = productInfo.sizes[0];
if (sizeInfo.price && sizeInfo.price.product) {
price = Math.round(sizeInfo.price.product / 100);
} else if (sizeInfo.price && sizeInfo.price.basic) {
price = Math.round(sizeInfo.price.basic / 100);
}
}
// If no price in sizes, try main fields
if (!price) {
if (productInfo.price) {
price = typeof productInfo.price === 'number' ? productInfo.price : parseInt(productInfo.price.toString()) || 0;
} else if (productInfo.priceU) {
// WB sometimes uses priceU in kopecks
price = Math.round(productInfo.priceU / 100);
} else if (productInfo.salePriceU) {
price = Math.round(productInfo.salePriceU / 100);
} else if (productInfo.basicPriceU) {
price = Math.round(productInfo.basicPriceU / 100);
} else if (productInfo.extended && productInfo.extended.basicPriceU) {
price = Math.round(productInfo.extended.basicPriceU / 100);
} else if (productInfo.extended && productInfo.extended.clientPriceU) {
price = Math.round(productInfo.extended.clientPriceU / 100);
}
}
// Log what we found for debugging
if (price > 0) {
console.log(` 💰 Цена найдена в API: ${price}`);
} else {
console.log(` ⚠️ Цена не найдена в API данных`);
}
// Generate image URL with multiple fallbacks
let imageUrl = '';
// Calculate correct basket number
const articleNum = parseInt(article);
const vol = Math.floor(articleNum / 100000);
const part = Math.floor(articleNum / 1000);
// Determine basket number - WB uses vol directly as basket number
const basketNum = vol.toString();
if (productInfo.pics && productInfo.pics[0]) {
// pics может содержать только номер изображения, например "1"
const picId = productInfo.pics[0];
imageUrl = `https://basket-${basketNum}.wbbasket.ru/vol${vol}/part${part}/${article}/images/c516x688/${picId}.webp`;
} else if (productInfo.media && productInfo.media.photo && productInfo.media.photo[0]) {
// Альтернативный путь через media.photo
const picId = productInfo.media.photo[0];
imageUrl = `https://basket-${basketNum}.wbbasket.ru/vol${vol}/part${part}/${article}/images/c516x688/${picId}.webp`;
} else if (productInfo.images && productInfo.images[0]) {
imageUrl = productInfo.images[0];
if (!imageUrl.startsWith('http')) {
imageUrl = `https:${imageUrl}`;
}
} else {
// Standard WB image pattern - всегда используем 1.webp как первое изображение
imageUrl = `https://basket-${basketNum}.wbbasket.ru/vol${vol}/part${part}/${article}/images/c516x688/1.webp`;
}
// Дополнительная проверка и корректировка URL
if (imageUrl.includes('basket-') && !imageUrl.includes('.webp')) {
// Если URL не содержит расширение, добавляем .webp
imageUrl += '.webp';
}
// Если всё ещё пусто или ссылка невалидна, попробуем og:image как быстрый фолбэк
if (!imageUrl || imageUrl.includes('no-image')) {
const og = await fetchOgImageUrl(article);
if (og) imageUrl = og;
}
const result = {
name,
brand,
price,
article,
imageUrl
};
console.log(` ✅ Данные получены через API: ${name} - ${result.price}`);
console.log(` 🖼️ URL изображения: ${imageUrl}`);
console.log(` 📊 Артикул: ${article}, Vol: ${vol}, Part: ${part}, Basket: ${basketNum}`);
return result;
}
}
} catch (apiError) {
console.log(` ❌ API endpoint недоступен:`, (apiError as Error).message);
continue;
}
}
} catch (error) {
console.log(`Ошибка API получения данных товара:`, (error as Error).message);
}
return null;
}
// Легкий фолбэк: получить изображение из meta og:image на странице товара без браузера
async function fetchOgImageUrl(article: string): Promise<string | null> {
try {
const url = `https://www.wildberries.ru/catalog/${article}/detail.aspx`;
const res = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'ru-RU,ru;q=0.9',
'Referer': 'https://www.wildberries.ru/'
}
});
if (!res.ok) return null;
const html = await res.text();
const $ = cheerio.load(html);
const og = $('meta[property="og:image"]').attr('content');
if (og && typeof og === 'string' && og.length > 0) {
return og.startsWith('http') ? og : `https:${og}`;
}
} catch (_) {
// ignore
}
return null;
}
// Main product data function with working cheerio parsing
async function getProductData(article: string): Promise<ProductData | null> {
console.log(`Загрузка данных о товаре: https://www.wildberries.ru/catalog/${article}/detail.aspx`);
let browser, page;
// First try to get data via API (much more reliable)
const apiData = await getProductDataViaAPI(article);
if (apiData) {
return apiData;
}
console.log(`⚠️ API не дал результатов для товара ${article}, пробуем браузерный парсинг...`);
try {
const browserData = await createStealthBrowser();
browser = browserData.browser;
page = browserData.page;
await page.goto(`https://www.wildberries.ru/catalog/${article}/detail.aspx`, {
waitUntil: 'networkidle2',
timeout: 30000
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--no-first-run',
'--no-zygote',
'--disable-gpu',
],
timeout: 30000,
});
const page = await browser.newPage();
// Ускоряем загрузку страницы блокировкой ненужных ресурсов
await page.setRequestInterception(true);
page.on('request', (req) => {
const resourceType = req.resourceType();
if (
resourceType === 'stylesheet' ||
resourceType === 'font' ||
resourceType === 'media' ||
req.url().includes('yandex') ||
req.url().includes('google') ||
req.url().includes('analytics')
) {
req.abort();
} else {
req.continue();
}
});
// Устанавливаем User-Agent как у обычного браузера
await page.setUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
);
// Открываем страницу товара
const url = `https://www.wildberries.ru/catalog/${article}/detail.aspx`;
try {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
} catch (e) {
console.log(`Ошибка загрузки страницы: ${e}. Пробуем альтернативный способ.`);
// Альтернативный подход в случае ошибки
await Promise.race([
page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 }),
new Promise(resolve => setTimeout(resolve, 15000))
]);
}
// Ждем, пока не загрузится основная информация
await page
.waitForSelector('.product-page__header, .catalog-page, .not-found-search', { timeout: 5000 })
.catch(() => {
console.log('Не удалось найти основной селектор, продолжаем без него');
});
// Проверка наличия сообщения "Товар не найден"
const notFoundElement = await page.$('.not-found-search');
if (notFoundElement) {
console.log(`Товар ${article} не найден на Wildberries`);
await browser.close();
return generateFallbackProductData(article);
}
// Проверяем, что мы попали на страницу товара, а не на главную
const currentUrl = page.url();
const title = await page.title();
await randomDelay(2000, 4000);
const selectors = {
name: '.product-page__header h1, .product-page__title, [data-link="text{:productName}"]',
brand: '.product-page__header-brand, .product-page__brand, [data-link="text{:brandName}"]',
price: '.price-block__final-price, .price__lower-price, [data-link="text{:priceFormatter(price)}"]',
image: '.preview__list img, .swiper-slide img, [data-link="src{:imageSrc}"]'
};
let name = '', brand = '', price = 0, imageUrl = '';
for (const [key, selector] of Object.entries(selectors)) {
try {
const element = await page.$(selector);
if (element) {
if (key === 'image') {
imageUrl = await element.evaluate((img: Element) => {
const htmlImg = img as HTMLImageElement;
return htmlImg.src || htmlImg.getAttribute('data-src') || '';
});
} else {
const text = await element.evaluate((el: Element) => el.textContent?.trim() || '');
if (key === 'name') name = text;
else if (key === 'brand') brand = text;
else if (key === 'price') {
// Убираем все пробелы и символы валют, оставляем только цифры
const cleanText = text.replace(/[^\d]/g, '');
if (cleanText) price = parseInt(cleanText);
if (!currentUrl.includes(article) || title.includes('Интернет‑магазин Wildberries')) {
console.log(`Не удалось найти товар ${article}, получили страницу: ${title}`);
await browser.close();
return generateFallbackProductData(article);
}
// Ждем загрузки основных элементов страницы
try {
await page.waitForSelector('.product-page__header, .product-card__header, .product-detail__header', { timeout: 5000 });
} catch (e) {
console.log('Продолжаем без ожидания селекторов');
}
// Получаем HTML страницы и используем cheerio для парсинга
const content = await page.content();
const $ = cheerio.load(content);
// Парсим данные товара с расширенным набором селекторов
let name = $('.product-page__header h1').text().trim() ||
$('.product-card__header h1').text().trim() ||
$('.product-detail__header h1').text().trim() ||
$('[data-link="text{:productName}"]').text().trim() ||
$('.product-page__title').text().trim() ||
$('h1').first().text().trim() ||
`Товар ${article}`;
// Получаем реальную цену
let price = 0;
try {
// Пытаемся найти цену в HTML
const priceEl = $('.price-block__final-price').first();
if (priceEl.length > 0) {
// Извлекаем числовое значение из текста цены
let priceText = priceEl.text().trim();
// Находим первое число с рублями в тексте
const priceMatch = priceText.match(/(\d[\d\s]*)\s*₽/);
if (priceMatch && priceMatch[1]) {
// Убираем все пробелы и нечисловые символы
priceText = priceMatch[1].replace(/\s+/g, '').replace(/[^\d]/g, '');
// Преобразуем в число
if (priceText && priceText.length > 0) {
price = parseInt(priceText, 10);
// Ограничиваем максимальную длину цены
if (price > 100000) {
const priceStr = price.toString();
price = parseInt(priceStr.substring(0, 5), 10);
}
console.log(`Получена реальная цена товара ${article}: ${price}`);
}
}
}
// Если цена не найдена, пробуем другие селекторы
if (!price) {
const alterPriceEl = $('.ins-product-price').first();
if (alterPriceEl.length > 0) {
let priceText = alterPriceEl.text().trim();
// Находим первое число с рублями в тексте
const priceMatch = priceText.match(/(\d[\d\s]*)\s*₽/);
if (priceMatch && priceMatch[1]) {
// Убираем все пробелы и нечисловые символы
priceText = priceMatch[1].replace(/\s+/g, '').replace(/[^\d]/g, '');
// Преобразуем в число
if (priceText && priceText.length > 0) {
price = parseInt(priceText, 10);
// Ограничиваем максимальную длину цены
if (price > 100000) {
const priceStr = price.toString();
price = parseInt(priceStr.substring(0, 5), 10);
}
console.log(`Получена альтернативная цена товара ${article}: ${price}`);
}
}
}
} catch (error) {
console.log(`Не удалось получить ${key}:`, (error as Error).message);
}
// Если всё равно не нашли цену
if (!price) {
// Ищем цену в любом формате на странице
const anyPriceEl = $('*:contains("₽")').filter(function () {
const hasMatch = $(this).text().match(/\d+\s*₽/) !== null;
return $(this).children().length === 0 && hasMatch;
});
if (anyPriceEl.length > 0) {
let priceText = anyPriceEl.first().text().trim();
const priceMatch = priceText.match(/(\d+)\s*₽/);
if (priceMatch) {
price = parseInt(priceMatch[1], 10);
if (price > 0 && price < 1000000) {
console.log(`Получена запасная цена товара ${article}: ${price}`);
}
}
}
}
} catch (e) {
console.log(`Ошибка при получении цены для ${article}:`, e);
}
// Если основные селекторы не сработали, генерируем недостающие данные
if (!name) name = `Товар ${article}`;
if (!brand) brand = 'Unknown Brand';
// Если цена не найдена, используем заглушку
if (!price) {
price = Math.floor(Math.random() * 1000) + 100;
price = Math.floor(Math.random() * 300) + 200;
console.log(`Не удалось получить цену для ${article}, использована случайная: ${price}`);
}
if (!imageUrl) {
// Пытаемся сгенерировать URL изображения на основе артикула
const vol = Math.floor(parseInt(article) / 100000);
const part = Math.floor(parseInt(article) / 1000);
imageUrl = `https://basket-${vol.toString().padStart(2, '0')}.wbbasket.ru/vol${vol}/part${part}/${article}/images/c516x688/1.webp`;
// Получаем бренд с расширенным набором селекторов
const brand = $('.product-page__brand-link').text().trim() ||
$('.product-card__brand').text().trim() ||
$('.product-detail__brand').text().trim() ||
$('.brand-name').text().trim() ||
$('[data-link="text{:brandName}"]').text().trim() ||
$('.product-page__header-brand').text().trim() ||
'Unknown Brand';
// Получаем изображение
let imageUrl = '/images/no-image.svg';
// Попробуем найти изображение в разных местах страницы
try {
// 1. Сначала ищем в основной галерее (на странице товара)
let imgSrc = $('.slider-content img').first().attr('src');
// 2. Если не нашли, пробуем найти в мобильной галерее
if (!imgSrc || imgSrc.length === 0 || imgSrc.includes('data:image')) {
imgSrc = $('.swiper-wrapper img').first().attr('src');
}
// 3. Проверяем наличие изображения в структуре имидж-контейнера
if (!imgSrc || imgSrc.length === 0 || imgSrc.includes('data:image')) {
imgSrc = $('.img-plug img').first().attr('src');
}
// 4. Проверяем дополнительные селекторы для изображений
if (!imgSrc || imgSrc.length === 0 || imgSrc.includes('data:image')) {
imgSrc = $('.product-card__img img').first().attr('src') ||
$('.product-detail__img img').first().attr('src') ||
$('[data-link="src{:imageSrc}"]').first().attr('src') ||
$('.zoom-image img').first().attr('src');
}
// 5. Пробуем meta og:image на странице товара (если уже что-то загрузилось)
if (!imgSrc || imgSrc.length === 0 || imgSrc.includes('data:image')) {
const og = $('meta[property="og:image"]').attr('content');
if (og && typeof og === 'string' && og.length > 0) {
imgSrc = og.startsWith('http') ? og : `https:${og}`;
}
}
// 6. Пытаемся извлечь из пути к товару (используя артикул)
if (!imgSrc || imgSrc.length === 0 || imgSrc.includes('data:image')) {
// Формируем путь к изображению по шаблону WB
const articleNum = parseInt(article);
const vol = Math.floor(articleNum / 100000);
const part = Math.floor(articleNum / 1000);
// WB использует vol напрямую как номер корзины
const basketNum = vol.toString();
imgSrc = `https://basket-${basketNum}.wbbasket.ru/vol${vol}/part${part}/${article}/images/c516x688/1.webp`;
}
if (imgSrc && imgSrc.length > 0 && !imgSrc.includes('data:image')) {
imageUrl = imgSrc.startsWith('http') ? imgSrc : `https:${imgSrc}`;
console.log(`Получено изображение: ${imageUrl}`);
} else {
console.log(`Не удалось найти изображение для товара ${article}, используем заглушку`);
}
} catch (imgError) {
console.log(`Ошибка при получении изображения: ${imgError}`);
}
await browser.close();
// Очищаем название от лишних символов
name = name.replace(/&quot;/g, '"').replace(/&amp;/g, '&').replace(/\s+/g, ' ').trim();
if (imageUrl) {
console.log(`Получено изображение: ${imageUrl}`);
}
return {
const result = {
name,
brand,
price,
@ -799,13 +1196,17 @@ async function getProductData(article: string): Promise<ProductData | null> {
imageUrl
};
console.log(`✅ Получены данные товара ${article}:`);
console.log(` 📦 Название: ${name}`);
console.log(` 🏷️ Бренд: ${brand}`);
console.log(` 💰 Цена: ${price}`);
console.log(` 🖼️ Изображение: ${imageUrl}`);
return result;
} catch (error) {
console.error('Ошибка при получении данных товара:', error);
return null;
} finally {
if (browser) {
await browser.close();
}
return generateFallbackProductData(article);
}
}
@ -817,9 +1218,15 @@ function generateFallbackProductData(article: string): ProductData {
const productTypes = ['Товар', 'Продукт', 'Изделие'];
const brands = ['Unknown Brand', 'NoName', 'Generic'];
const vol = Math.floor(parseInt(article) / 100000);
const part = Math.floor(parseInt(article) / 1000);
const imageUrl = `https://basket-${vol.toString().padStart(2, '0')}.wbbasket.ru/vol${vol}/part${part}/${article}/images/c516x688/1.webp`;
// Корректный расчет vol и part для URL изображения
const articleNum = parseInt(article);
const vol = Math.floor(articleNum / 100000);
const part = Math.floor(articleNum / 1000);
// WB использует vol напрямую как номер корзины
const basketNum = vol.toString();
const imageUrl = `https://basket-${basketNum}.wbbasket.ru/vol${vol}/part${part}/${article}/images/c516x688/1.webp`;
return {
name: `${productTypes[parseInt(article) % productTypes.length]} ${article}`,
@ -847,7 +1254,8 @@ export async function parseWildberries(
}
// 1. Получаем позиции для Москвы (как основного города)
const moscowPositions = await searchPositions(query, targetArticles, 'msk', enhancedScraping, maxItems);
const moscow = await searchPositions(query, targetArticles, 'msk', enhancedScraping, maxItems);
const moscowPositions = moscow.positions;
// 2. Собираем позиции в требуемом формате для всех городов
const positions: { [articleId: string]: CityPosition[] } = {};
@ -860,8 +1268,8 @@ export async function parseWildberries(
// Добавляем данные для Москвы
targetArticles.forEach(article => {
const position = moscowPositions[article];
const page = position ? Math.ceil(position / 30) : null;
const positionOnPage = position ? ((position - 1) % 30) + 1 : null;
const page = position ? Math.ceil(position / 100) : null;
const positionOnPage = position ? ((position - 1) % 100) + 1 : null;
positions[article].push({
city: 'Москва',
@ -875,20 +1283,43 @@ export async function parseWildberries(
for (const city of cities.slice(1)) {
console.log(`Генерация данных для города ${city.name} (${city.code})...`);
const cityPositions: { [article: string]: number | null } = {};
// Сначала генерируем позиции для всех артикулов
targetArticles.forEach(article => {
const moscowPosition = moscowPositions[article];
let generatedPosition = null;
let generatedPage = null;
let generatedPositionOnPage = null;
if (moscowPosition) {
// Генерируем позицию в пределах ±5 от московской
const variance = Math.floor(Math.random() * 11) - 5; // от -5 до +5
generatedPosition = Math.max(1, moscowPosition + variance);
generatedPage = Math.ceil(generatedPosition / 30);
generatedPositionOnPage = ((generatedPosition - 1) % 30) + 1;
}
cityPositions[article] = generatedPosition;
});
// Логируем, если случайно получились одинаковые позиции (в реальности это невозможно)
const articles = Object.keys(cityPositions);
for (let i = 0; i < articles.length; i++) {
for (let j = i + 1; j < articles.length; j++) {
const article1 = articles[i];
const article2 = articles[j];
const pos1 = cityPositions[article1];
const pos2 = cityPositions[article2];
if (pos1 && pos2 && pos1 === pos2) {
console.warn(`⚠️ ВНИМАНИЕ: Одинаковые сгенерированные позиции в ${city.name}: ${article1}=${pos1}, ${article2}=${pos2}. Это нормально для генерации, но в реальном каталоге невозможно.`);
}
}
}
// Добавляем позиции в результат
targetArticles.forEach(article => {
const generatedPosition = cityPositions[article];
const generatedPage = generatedPosition ? Math.ceil(generatedPosition / 100) : null;
const generatedPositionOnPage = generatedPosition ? ((generatedPosition - 1) % 100) + 1 : null;
positions[article].push({
city: city.name,
position: generatedPosition,
@ -901,8 +1332,34 @@ export async function parseWildberries(
// 3. Получаем данные о каждом товаре
const products: ProductData[] = [];
for (const article of targetArticles) {
console.log(`Получаем данные о товаре ${article}...`);
const data = await getProductData(article);
console.log(`Получаем данные о товаре ${article} (поиск через каталог)...`);
let data: ProductData | null = null;
try {
// 1) попробуем сразу взять из уже загруженного листинга
const listingFromSearch = moscow.listingDetails[article];
if (listingFromSearch && (listingFromSearch.name || listingFromSearch.imageUrl)) {
data = {
name: listingFromSearch.name || `Товар ${article}`,
brand: listingFromSearch.brand || 'Unknown Brand',
price: typeof listingFromSearch.price === 'number' ? listingFromSearch.price : 0,
article,
imageUrl: listingFromSearch.imageUrl || ''
};
}
// 2) если нет — точечный поиск в листинге
if (!data) {
data = await getProductDataFromListingSearch(query, article, enhancedScraping, maxItems);
}
} catch (listingErr) {
console.log(`⚠️ Не удалось получить данные из списка для ${article}:`, (listingErr as Error).message);
}
if (!data) {
console.log(`↩️ Пытаемся получить данные товара ${article} альтернативно (API/карточка)...`);
data = await getProductData(article);
}
if (data) {
products.push(data);
console.log(`✅ Получены данные товара ${article}: ${data.name} - ${data.price}`);
@ -917,4 +1374,377 @@ export async function parseWildberries(
products,
positions
};
}
// Извлекает данные о товаре прямо из страницы каталога (DOM) по артикулу
async function getProductDataFromListingSearch(
query: string,
article: string,
enhancedScraping: boolean,
maxItems: number
): Promise<ProductData | null> {
let browser: any | null = null;
let page: any | null = null;
try {
const browserData = await createStealthBrowser();
browser = browserData.browser;
page = browserData.page;
// Переходим на страницу поиска
const searchUrl = `https://www.wildberries.ru/catalog/0/search.aspx?search=${encodeURIComponent(query)}&page=1`;
console.log(`🔎 Открываем поиск для извлечения карточки: ${searchUrl}`);
const ok = await robustNavigation(page, searchUrl);
if (!ok) {
throw new Error('Не удалось открыть страницу поиска');
}
await randomDelay(1000, 2500);
// Загружаем больше товаров, если включен расширенный режим
if (enhancedScraping) {
await scrollToLoadMoreProducts(page, maxItems);
} else {
try {
await page.waitForSelector('[data-nm-id], .product-card, .j-card-item', { timeout: 15000 });
} catch {
// продолжаем попробуем все равно прочитать DOM
}
}
// Пробуем извлечь данные на текущей странице
const listingData = await extractListingDataForArticleOnPage(page, article);
if (listingData) {
await browser.close();
return listingData;
}
// Если не нашли попробуем по пагинации пройтись ещё по нескольким страницам
for (let pageNum = 2; pageNum <= 5; pageNum++) {
const url = `https://www.wildberries.ru/catalog/0/search.aspx?search=${encodeURIComponent(query)}&page=${pageNum}`;
console.log(`📄 Переходим на страницу ${pageNum} для поиска карточки: ${url}`);
const okPage = await robustNavigation(page, url);
if (!okPage) {
break;
}
await randomDelay(1000, 2500);
const dataOnPage = await extractListingDataForArticleOnPage(page, article);
if (dataOnPage) {
await browser.close();
return dataOnPage;
}
}
await browser.close();
return null;
} catch (error) {
if (browser) {
try { await browser.close(); } catch {}
}
console.log(`Ошибка получения данных из каталога для ${article}:`, (error as Error).message);
return null;
}
}
// Вспомогательная: достать из DOM текущей страницы позицию, имя, бренд, цену и картинку для артикула
async function extractListingDataForArticleOnPage(page: any, article: string): Promise<ProductData | null> {
type ListingExtract = {
position: number | null;
name?: string;
brand?: string;
price?: number;
imageUrl?: string;
};
const data: ListingExtract = await page.evaluate((targetArticle: string) => {
const candidateSelectors = [
'article[data-nm-id]',
'[data-nm-id]',
'.product-card',
'.j-card-item',
'.goods-tile'
];
// Собираем уникальные карточки и вычисляем позиции по порядку появления
const uniqueIds: string[] = [];
let found: ListingExtract | null = null;
const getArticleFromElement = (el: Element): string | null => {
const direct = el.getAttribute('data-nm-id');
if (direct && /\d+/.test(direct)) return direct;
// Пытаемся вытащить из ссылки
const a = el.querySelector('a[href*="/catalog/"]') as HTMLAnchorElement | null;
if (a && a.href) {
const m = a.href.match(/\/catalog\/(\d+)\/detail\.aspx/);
if (m && m[1]) return m[1];
}
return null;
};
const normalizePrice = (txt?: string | null): number | undefined => {
if (!txt) return undefined;
const cleaned = txt.replace(/[^\d]/g, '');
if (!cleaned) return undefined;
const num = parseInt(cleaned, 10);
return isNaN(num) ? undefined : num;
};
const getImageFromCard = (card: Element): string | undefined => {
// приоритет: data-src-pb -> data-src -> src
const img = card.querySelector('img.j-thumbnail, img');
if (!img) return undefined;
const dsPb = (img as HTMLImageElement).getAttribute('data-src-pb');
const ds = (img as HTMLImageElement).getAttribute('data-src');
const src = (img as HTMLImageElement).getAttribute('src');
const val = dsPb || ds || src || '';
if (!val) return undefined;
if (val.startsWith('http')) return val;
if (val.startsWith('//')) return `https:${val}`;
return val; // бывает уже абсолютный
};
for (const selector of candidateSelectors) {
const elements = Array.from(document.querySelectorAll(selector));
if (elements.length === 0) continue;
for (const el of elements) {
const nmId = getArticleFromElement(el);
if (!nmId) continue;
if (!uniqueIds.includes(nmId)) {
uniqueIds.push(nmId);
}
if (nmId === targetArticle && !found) {
// имя
const name =
(el.querySelector('[title]') as HTMLElement | null)?.getAttribute('title') ||
(el.querySelector('.product-card__name') as HTMLElement | null)?.textContent?.trim() ||
(el.querySelector('img.j-thumbnail') as HTMLImageElement | null)?.alt ||
undefined;
// бренд
const brand =
(el.querySelector('.product-card__brand') as HTMLElement | null)?.textContent?.trim() ||
undefined;
// цена несколько вариантов
const priceText =
(el.querySelector('.price__lower-price') as HTMLElement | null)?.textContent ||
(el.querySelector('.lower-price') as HTMLElement | null)?.textContent ||
(el.querySelector('[data-qa="product-card-price"]') as HTMLElement | null)?.textContent ||
(el.querySelector('ins') as HTMLElement | null)?.textContent ||
(el.querySelector('span') as HTMLElement | null)?.textContent ||
'';
const price = normalizePrice(priceText);
// картинка
const imageUrl = getImageFromCard(el);
found = {
position: uniqueIds.indexOf(nmId) + 1,
name,
brand,
price,
imageUrl
};
}
}
if (found) break;
}
return found;
}, article);
if (!data || !data.position) {
return null;
}
// Страхующий расчёт URL картинки по шаблону WB, если нет валидной ссылки
let finalImage = data.imageUrl || '';
if (!finalImage || !/^https?:\/\//.test(finalImage)) {
const articleNum = parseInt(article);
const vol = Math.floor(articleNum / 100000);
const part = Math.floor(articleNum / 1000);
const basketNum = vol.toString();
finalImage = `https://basket-${basketNum}.wbbasket.ru/vol${vol}/part${part}/${article}/images/c516x688/1.webp`;
}
// Если цена не найдена вернём 0, дальше можно дообогатить через API
const result: ProductData = {
name: data.name || `Товар ${article}`,
brand: data.brand || 'Unknown Brand',
price: typeof data.price === 'number' ? data.price : 0,
article,
imageUrl: finalImage
};
// Валидация URL: если это basket-* и без .webp добавим
if (result.imageUrl.includes('basket-') && !result.imageUrl.endsWith('.webp')) {
result.imageUrl = `${result.imageUrl}.webp`;
}
// Если вдруг картинка всё ещё сомнительная попробуем og:image как быстрый фолбэк
if (!result.imageUrl || result.imageUrl.includes('no-image')) {
const og = await fetchOgImageUrl(article);
if (og) result.imageUrl = og;
}
// Если не нашли цену в листинге попробуем быстро добрать через API (не открывая карточку)
if (!result.price || result.price <= 0) {
try {
const api = await getProductDataViaAPI(article);
if (api && api.price) {
result.price = api.price;
}
if (api && (!result.name || result.name.startsWith('Товар '))) {
result.name = api.name;
}
if (api && result.imageUrl.includes('no-image')) {
result.imageUrl = api.imageUrl;
}
} catch {}
}
return result;
}
// Извлекает данные по нескольким артикулам из уже загруженной страницы каталога (одним проходом)
async function extractListingDataForArticlesOnPage(
page: any,
targetArticles: string[]
): Promise<{ [articleId: string]: Partial<ProductData> & { position?: number | null } }> {
type ListingExtract = {
position: number | null;
name?: string;
brand?: string;
price?: number;
imageUrl?: string;
article?: string;
};
const result: { [articleId: string]: Partial<ProductData> & { position?: number | null } } = {};
const data: ListingExtract[] = await page.evaluate(() => {
const candidateSelectors = [
'article[data-nm-id]',
'[data-nm-id]',
'.product-card',
'.j-card-item',
'.goods-tile'
];
const normalizePrice = (txt?: string | null): number | undefined => {
if (!txt) return undefined;
const cleaned = txt.replace(/[^\d]/g, '');
if (!cleaned) return undefined;
const num = parseInt(cleaned, 10);
return isNaN(num) ? undefined : num;
};
const getArticleFromElement = (el: Element): string | null => {
const direct = el.getAttribute('data-nm-id');
if (direct && /\d+/.test(direct)) return direct;
const a = el.querySelector('a[href*="/catalog/"]') as HTMLAnchorElement | null;
if (a && a.href) {
const m = a.href.match(/\/catalog\/(\d+)\/detail\.aspx/);
if (m && m[1]) return m[1];
}
return null;
};
const getImageFromCard = (card: Element): string | undefined => {
const img = card.querySelector('img.j-thumbnail, img');
if (!img) return undefined;
const dsPb = (img as HTMLImageElement).getAttribute('data-src-pb');
const ds = (img as HTMLImageElement).getAttribute('data-src');
const src = (img as HTMLImageElement).getAttribute('src');
const val = dsPb || ds || src || '';
if (!val) return undefined;
if (val.startsWith('http')) return val;
if (val.startsWith('//')) return `https:${val}`;
return val;
};
const uniqueIds: string[] = [];
const extracted: ListingExtract[] = [];
for (const selector of candidateSelectors) {
const elements = Array.from(document.querySelectorAll(selector));
if (elements.length === 0) continue;
for (const el of elements) {
const nmId = getArticleFromElement(el);
if (!nmId) continue;
if (!uniqueIds.includes(nmId)) {
uniqueIds.push(nmId);
}
const pos = uniqueIds.indexOf(nmId) + 1;
// имя
const name =
(el.querySelector('[title]') as HTMLElement | null)?.getAttribute('title') ||
(el.querySelector('.product-card__name') as HTMLElement | null)?.textContent?.trim() ||
(el.querySelector('img.j-thumbnail') as HTMLImageElement | null)?.alt ||
undefined;
// бренд
const brand =
(el.querySelector('.product-card__brand') as HTMLElement | null)?.textContent?.trim() ||
undefined;
// цена
const priceText =
(el.querySelector('.price__lower-price') as HTMLElement | null)?.textContent ||
(el.querySelector('.lower-price') as HTMLElement | null)?.textContent ||
(el.querySelector('[data-qa="product-card-price"]') as HTMLElement | null)?.textContent ||
(el.querySelector('ins') as HTMLElement | null)?.textContent ||
(el.querySelector('span') as HTMLElement | null)?.textContent ||
'';
const price = normalizePrice(priceText);
const imageUrl = getImageFromCard(el);
extracted.push({
position: pos,
name,
brand,
price,
imageUrl,
article: nmId
});
}
}
return extracted;
});
for (const item of data) {
const id = item.article || '';
if (!id) continue;
if (!targetArticles.includes(id)) continue;
result[id] = {
position: item.position ?? null,
name: item.name,
brand: item.brand,
price: item.price,
imageUrl: item.imageUrl
};
}
// Проверяем на одинаковые позиции (в реальном каталоге такого быть не должно)
const articles = Object.keys(result);
for (let i = 0; i < articles.length; i++) {
for (let j = i + 1; j < articles.length; j++) {
const article1 = articles[i];
const article2 = articles[j];
const pos1 = result[article1].position;
const pos2 = result[article2].position;
if (pos1 && pos2 && pos1 === pos2) {
console.error(`❌ ОШИБКА ПАРСИНГА: Одинаковые позиции в реальном листинге: ${article1}=${pos1}, ${article2}=${pos2}. Это указывает на проблему в алгоритме извлечения позиций!`);
}
}
}
return result;
}

View File

@ -2,7 +2,7 @@
import React, { useEffect, useRef } from 'react';
import { motion } from 'framer-motion';
import { FiBarChart, FiActivity } from 'react-icons/fi';
import { FiActivity } from 'react-icons/fi';
interface ChartDataPoint {
city: string;
@ -25,13 +25,11 @@ const PositionChart: React.FC<PositionChartProps> = ({
const chartRef = useRef<any>(null);
const scriptsLoadedRef = useRef<boolean>(false);
// Загружаем скрипты AnyChart
// Загружаем скрипты AnyChart (строго по порядку)
const loadAnyChartScripts = () => {
return new Promise<void>((resolve, reject) => {
if (scriptsLoadedRef.current && window.anychart) {
resolve();
return;
}
return new Promise<void>(async (resolve, reject) => {
if (typeof window === 'undefined') return resolve();
if (scriptsLoadedRef.current && (window as any).anychart) return resolve();
const scripts = [
'https://cdn.anychart.com/releases/v8/js/anychart-base.min.js',
@ -40,137 +38,102 @@ const PositionChart: React.FC<PositionChartProps> = ({
'https://cdn.anychart.com/releases/v8/js/anychart-data-adapter.min.js'
];
let loadedCount = 0;
scripts.forEach((src) => {
const script = document.createElement('script');
const loadScript = (src: string) => new Promise<void>((res, rej) => {
// Не дублируем
const existing = document.querySelector(`script[src="${src}"]`) as HTMLScriptElement | null;
if (existing && existing.getAttribute('data-loaded') === 'true') return res();
const script = existing || document.createElement('script');
script.src = src;
script.onload = () => {
loadedCount++;
if (loadedCount === scripts.length) {
scriptsLoadedRef.current = true;
resolve();
}
};
script.onerror = () => reject(new Error(`Failed to load script: ${src}`));
document.head.appendChild(script);
script.async = false; // важен порядок
script.onload = () => { script.setAttribute('data-loaded', 'true'); res(); };
script.onerror = () => rej(new Error(`Failed to load script: ${src}`));
if (!existing) document.head.appendChild(script);
});
// Загружаем CSS
const link = document.createElement('link');
link.href = 'https://cdn.anychart.com/releases/v8/css/anychart-ui.min.css';
link.type = 'text/css';
link.rel = 'stylesheet';
document.head.appendChild(link);
try {
for (const src of scripts) {
// eslint-disable-next-line no-await-in-loop
await loadScript(src);
}
// CSS
const link = document.querySelector('link[href*="anychart-ui.min.css"]') as HTMLLinkElement | null || document.createElement('link');
link.href = 'https://cdn.anychart.com/releases/v8/css/anychart-ui.min.css';
link.type = 'text/css';
link.rel = 'stylesheet';
if (!link.parentElement) document.head.appendChild(link);
const fontLink = document.createElement('link');
fontLink.href = 'https://cdn.anychart.com/releases/v8/fonts/css/anychart-font.min.css';
fontLink.type = 'text/css';
fontLink.rel = 'stylesheet';
document.head.appendChild(fontLink);
const fontLink = document.querySelector('link[href*="anychart-font.min.css"]') as HTMLLinkElement | null || document.createElement('link');
fontLink.href = 'https://cdn.anychart.com/releases/v8/fonts/css/anychart-font.min.css';
fontLink.type = 'text/css';
fontLink.rel = 'stylesheet';
if (!fontLink.parentElement) document.head.appendChild(fontLink);
scriptsLoadedRef.current = true;
resolve();
} catch (err) {
reject(err as Error);
}
});
};
// Преобразуем данные для Box and Whisker диаграммы с исправлением одинаковых значений
const prepareBoxData = (data: ChartDataPoint[]) => {
if (!data || data.length === 0) {
return [];
}
return data.map((item) => {
// Собираем все позиции (исключаем нулевые и отрицательные)
const positions = [item.myPosition, item.competitorPosition].filter(p => p > 0);
// Преобразование данных для column chart с цветовой логикой (один столбец = средняя позиция)
const prepareColumnData = (points: ChartDataPoint[]) => {
if (!points || points.length === 0) return [];
return points.map((item) => {
const myPos = item.myPosition > 0 ? item.myPosition : null;
const compPos = item.competitorPosition > 0 ? item.competitorPosition : null;
if (positions.length === 0) {
// Если нет валидных позиций, создаем небольшой видимый бокс на позиции 1
const basePos = 1;
const boxWidth = 0.6;
return {
x: item.city,
low: basePos - boxWidth / 2,
q1: basePos - boxWidth / 4,
median: basePos,
q3: basePos + boxWidth / 4,
high: basePos + boxWidth / 2,
outliers: []
};
// Вычисляем среднюю позицию
const positions = [myPos, compPos].filter(p => p !== null) as number[];
let averagePosition = null;
let color = '#8b5cf6'; // Purple default
if (positions.length > 0) {
averagePosition = positions.reduce((a, b) => a + b, 0) / positions.length;
// Определяем цвет: кто лучше (меньшая позиция)
if (myPos && compPos) {
if (myPos < compPos) {
color = '#22c55e'; // Green - мой лучше
} else if (myPos > compPos) {
color = '#ef4444'; // Red - конкурент лучше
}
// Удалили amber цвет для одинаковых позиций, так как это невозможно в реальном каталоге
} else if (myPos) {
color = '#8b5cf6'; // Purple - только мой
} else if (compPos) {
color = '#a855f7'; // Light purple - только конкурент
}
}
if (positions.length === 1) {
// Если только одна позиция, создаем небольшой видимый бокс
const pos = positions[0];
const boxWidth = 0.6; // Ширина бокса для одной позиции
return {
x: item.city,
low: pos - boxWidth / 2,
q1: pos - boxWidth / 4,
median: pos,
q3: pos + boxWidth / 4,
high: pos + boxWidth / 2,
outliers: []
};
}
// Если две позиции, создаем видимый box plot
let [pos1, pos2] = positions;
// Если позиции одинаковые, добавляем небольшое смещение для визуализации
if (pos1 === pos2) {
pos2 = pos1 + 0.5; // Увеличиваем смещение для видимости
}
const min = Math.min(pos1, pos2);
const max = Math.max(pos1, pos2);
const median = (pos1 + pos2) / 2;
// Создаем искусственные квартили для видимого бокса
const range = max - min;
const q1 = min + range * 0.25; // 25% от диапазона
const q3 = min + range * 0.75; // 75% от диапазона
// Если диапазон слишком мал, создаем минимальную ширину бокса
const minBoxWidth = 0.8;
let adjustedQ1 = q1;
let adjustedQ3 = q3;
if (q3 - q1 < minBoxWidth) {
const center = median;
adjustedQ1 = center - minBoxWidth / 2;
adjustedQ3 = center + minBoxWidth / 2;
}
return {
x: item.city,
low: min,
q1: adjustedQ1,
median: median,
q3: adjustedQ3,
high: max,
outliers: []
value: averagePosition,
fill: color,
myPosition: myPos,
competitorPosition: compPos
};
});
};
// Создание и настройка графика
// Создание/обновление графика
const createChart = async () => {
if (!chartContainerRef.current || !window.anychart || !data || data.length === 0) return;
if (!chartContainerRef.current || !(window as any).anychart || !data || data.length === 0) return;
// Финальная проверка API наличия фабрики column
if (typeof (window as any).anychart.column !== 'function') return;
try {
// Удаляем предыдущий график
if (chartRef.current) {
chartRef.current.dispose();
chartRef.current = null;
}
// Подготавливаем данные
const boxData = prepareBoxData(data);
if (boxData.length === 0) return;
const columnData = prepareColumnData(data);
if (columnData.length === 0) return;
// Создаем Box диаграмму
const chart = window.anychart.box();
const chart = (window as any).anychart.column();
// Настраиваем заголовок
const title = chart.title('Сравнение позиций товаров по городам');
if (title && typeof title === 'object' && 'fontColor' in title) {
(title as any).fontColor('#7c3aed');
@ -178,116 +141,114 @@ const PositionChart: React.FC<PositionChartProps> = ({
(title as any).fontWeight(600);
}
// Настраиваем оси
chart.yAxis().title('Позиция');
chart.yAxis().title('Средняя позиция');
chart.yAxis().labels().format('{%value}');
try {
(chart.yAxis().labels() as any).fontColor('#6b7280');
} catch (error) {
console.log('Не удалось настроить цвет меток Y:', error);
}
try { (chart.yAxis().labels() as any).fontColor('#6b7280'); } catch {}
chart.xAxis().title('Города');
chart.xAxis().staggerMode(true);
try {
(chart.xAxis().labels() as any).fontColor('#6b7280');
} catch (error) {
console.log('Не удалось настроить цвет меток X:', error);
}
try { (chart.xAxis().labels() as any).fontColor('#6b7280'); } catch {}
// Настраиваем шкалу Y (инвертируем для позиций - 1 сверху)
// Инвертируем ось Y (меньшая позиция = лучше = выше на графике)
try {
const yScale = (chart as any).yScale();
if (yScale && typeof yScale.inverted === 'function') {
yScale.inverted(true);
}
} catch (error) {
console.log('Не удалось инвертировать ось Y:', error);
}
if (yScale && typeof yScale.inverted === 'function') yScale.inverted(true);
} catch {}
// Создаем серию с простым фиолетовым стилем
const series = (chart as any).box(boxData);
// Создаем единственную серию для средних позиций
const series = chart.column(columnData);
series.name('Средняя позиция');
// Настраиваем внешний вид с purple theme
try {
series.whiskerWidth('60%');
series.fill('#8b5cf6', 0.4);
series.stroke('#7c3aed', 2);
series.whiskerStroke('#7c3aed', 2);
series.medianStroke('#6d28d9', 3);
// Настраиваем цвет для каждого столбца индивидуально
series.fill((ctx: any) => ctx.getData('fill'));
series.stroke((ctx: any) => ctx.getData('fill'));
series.tooltip().format((ctx: any) => {
const myPos = ctx.getData('myPosition');
const compPos = ctx.getData('competitorPosition');
let tooltip = 'Город: ' + ctx.getData('x') + '\nСредняя позиция: ' + ctx.getData('value');
if (myPos && compPos) {
tooltip += '\n\n' + myArticleId + ': ' + myPos;
tooltip += '\n' + competitorArticleId + ': ' + compPos;
} else if (myPos) {
tooltip += '\n\nТолько ' + myArticleId + ': ' + myPos;
} else if (compPos) {
tooltip += '\n\nТолько ' + competitorArticleId + ': ' + compPos;
}
return tooltip;
});
// Настраиваем подсказки
series.tooltip().format(
'Город: {%x}' +
'\nМин позиция: {%low}' +
'\nМакс позиция: {%high}' +
'\nМедиана: {%median}'
);
} catch (error) {
console.log('Ошибка при настройке серии:', error);
}
// Настраиваем сетку
// Настройка сетки
try {
(chart as any).grid(0).stroke('#e5e7eb', 1, '2 2');
(chart as any).grid(1).layout('vertical').stroke('#e5e7eb', 1, '2 2');
} catch (error) {
console.log('Не удалось настроить сетку:', error);
} catch {
try {
(chart as any).yGrid(true);
(chart as any).xGrid(true);
} catch (gridError) {
console.log('Альтернативная настройка сетки также недоступна:', gridError);
}
} catch {}
}
// Настраиваем фон
(chart as any).background().fill('transparent');
// Легенда
const legend = chart.legend();
legend.enabled(true);
legend.fontSize(11);
legend.padding([10, 0, 0, 0]);
// Устанавливаем контейнер и отрисовываем
(chart as any).background().fill('transparent');
(chart as any).container(chartContainerRef.current);
(chart as any).draw();
chartRef.current = chart;
} catch (error) {
console.error('Ошибка при создании графика:', error);
} catch (e) {
console.error('Ошибка при создании графика:', e);
}
};
// Инициализация компонента
useEffect(() => {
const initChart = async () => {
const init = async () => {
try {
await loadAnyChartScripts();
// Дождёмся готовности AnyChart (poll, чтобы избежать несовместимости)
const waitReady = async () => {
const maxTries = 20;
for (let i = 0; i < maxTries; i++) {
if ((window as any).anychart && typeof (window as any).anychart.column === 'function') return;
await new Promise(res => setTimeout(res, 150));
}
};
await waitReady();
await createChart();
} catch (error) {
console.error('Ошибка при инициализации графика:', error);
} catch (e) {
console.error('Ошибка инициализации графика:', e);
}
};
initChart();
init();
return () => {
if (chartRef.current) {
chartRef.current.dispose();
chartRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Обновление данных
useEffect(() => {
if (scriptsLoadedRef.current && window.anychart) {
createChart();
}
const update = async () => {
if (!scriptsLoadedRef.current || !(window as any).anychart) return;
// Убедимся, что AnyChart готов
if (typeof (window as any).anychart.column !== 'function') return;
await createChart();
};
update();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, myArticleId, competitorArticleId]);
if (!data || data.length === 0) {
return (
<div className="bg-white/40 backdrop-blur-md border border-white/30 rounded-lg md:rounded-xl shadow-lg shadow-purple-900/5 p-4 md:p-6 h-full flex flex-col">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@ -306,8 +267,6 @@ const PositionChart: React.FC<PositionChartProps> = ({
return (
<div className="bg-white/40 backdrop-blur-md border border-white/30 rounded-lg md:rounded-xl shadow-lg shadow-purple-900/5 p-4 md:p-6 h-full flex flex-col">
{/* График */}
<motion.div
initial={{ opacity: 0 }}
@ -321,24 +280,27 @@ const PositionChart: React.FC<PositionChartProps> = ({
/>
</motion.div>
{/* Minimal Legend */}
{/* Минимальная легенда */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
className="flex-shrink-0 mt-3 pt-3 border-t border-white/20"
>
<div className="flex justify-center items-center gap-6 text-xs text-gray-600">
<div className="flex justify-center items-center gap-4 text-xs text-gray-600">
<div className="flex items-center gap-1.5">
<div className="w-3 h-2 bg-gradient-to-r from-purple-500 to-purple-600 rounded-sm"></div>
<span>{myArticleId}</span>
<div className="w-3 h-2 bg-green-500 rounded-sm"></div>
<span>{myArticleId} лучше</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-2 bg-red-500 rounded-sm"></div>
<span>{competitorArticleId || 'Конкурент'} лучше</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-2 bg-purple-500 rounded-sm"></div>
<span>Один товар</span>
</div>
{competitorArticleId && (
<div className="flex items-center gap-1.5">
<div className="w-3 h-2 bg-gradient-to-r from-purple-300 to-purple-400 rounded-sm"></div>
<span>{competitorArticleId}</span>
</div>
)}
</div>
</motion.div>
</div>

View File

@ -1,16 +1,15 @@
import { PrismaClient } from '../../generated/prisma';
import { PrismaClient } from '@prisma/client';
// PrismaClient является тяжелым для инстанцирования,
// поэтому мы глобально сохраняем одиночный экземпляр
declare global {
// eslint-disable-next-line no-var
var prisma: PrismaClient | undefined;
}
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = global.prisma || new PrismaClient();
const prisma = globalForPrisma.prisma ?? new PrismaClient({
log: ['query', 'error', 'warn'],
});
if (process.env.NODE_ENV !== 'production') {
global.prisma = prisma;
}
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
export default prisma;

View File

@ -92,8 +92,7 @@ export default function Home() {
// Обновляем графические данные
const chartDataFromHistory = historyItem.positions.map((pos) => ({
city: pos.city,
myPosition:
pos.pageRank === 1 ? pos.rank : (pos.pageRank - 1) * 100 + pos.rank,
myPosition: pos.pageRank === 1 ? pos.rank : (pos.pageRank - 1) * 100 + pos.rank,
// В истории нет данных конкурента, используем заглушку
competitorPosition:
pos.competitorRank && pos.competitorPageRank

View File

@ -176,6 +176,7 @@ const config = {
"db"
],
"activeProvider": "postgresql",
"postinstall": false,
"inlineDatasources": {
"db": {
"url": {

View File

@ -177,6 +177,7 @@ const config = {
"db"
],
"activeProvider": "postgresql",
"postinstall": false,
"inlineDatasources": {
"db": {
"url": {

38
test-basket-logic.js Normal file
View File

@ -0,0 +1,38 @@
// Проверка логики формирования basket
function getBasketNum(article) {
const articleNum = parseInt(article);
const vol = Math.floor(articleNum / 100000);
const part = Math.floor(articleNum / 1000);
// Из логов видно, что используется сам vol как basket
const basketNum = vol.toString();
return {
article,
vol,
part,
basketNum,
url: `https://basket-${basketNum}.wbbasket.ru/vol${vol}/part${part}/${article}/images/c516x688/1.webp`
};
}
// Тестовые артикулы
const tests = [
{ article: '240122176', expectedBasket: '2401' }, // из логов
{ article: '466992246', expectedBasket: '4669' }, // из логов
];
console.log('Проверка формирования basket:\n');
tests.forEach(test => {
const result = getBasketNum(test.article);
const isCorrect = result.basketNum === test.expectedBasket;
console.log(`Артикул: ${test.article}`);
console.log(` Vol: ${result.vol}, Part: ${result.part}`);
console.log(` Ожидается basket: ${test.expectedBasket}`);
console.log(` Получен basket: ${result.basketNum} ${isCorrect ? '✅' : '❌'}`);
console.log(` URL: ${result.url}`);
console.log('');
});

59
test-image-urls.js Normal file
View File

@ -0,0 +1,59 @@
// Тест формирования URL изображений для разных артикулов
function getImageUrl(article) {
const articleNum = parseInt(article);
const vol = Math.floor(articleNum / 100000);
const part = Math.floor(articleNum / 1000);
// Определяем номер корзины в зависимости от диапазона артикула
let basketNum;
if (articleNum < 14400000) {
basketNum = vol.toString().padStart(2, '0');
} else if (articleNum < 32800000) {
basketNum = (Math.floor(vol / 100) + 1).toString().padStart(2, '0');
} else if (articleNum < 72000000) {
basketNum = (Math.floor(vol / 100) + 2).toString().padStart(2, '0');
} else if (articleNum < 166400000) {
basketNum = (Math.floor(vol / 100) + 3).toString().padStart(2, '0');
} else {
basketNum = (Math.floor(vol / 100) + 4).toString().padStart(2, '0');
}
const imageUrl = `https://basket-${basketNum}.wbbasket.ru/vol${vol}/part${part}/${article}/images/c516x688/1.webp`;
return {
article,
articleNum,
vol,
part,
basketNum,
imageUrl
};
}
// Тестируем артикулы из логов
const testArticles = [
'240122176', // vol: 2401, basket: ?
'466992246', // vol: 4669, basket: ?
'281810311', // vol: 2818, basket: ?
'221321827', // vol: 2213, basket: ?
];
console.log('Тестирование формирования URL изображений:\n');
testArticles.forEach(article => {
const result = getImageUrl(article);
console.log(`Артикул: ${result.article}`);
console.log(` Vol: ${result.vol}, Part: ${result.part}, Basket: ${result.basketNum}`);
console.log(` URL: ${result.imageUrl}`);
console.log('');
});
// Проверяем корректность URL для артикула 240122176
// Из логов видно, что изображение должно быть на basket-2401
const expected240 = 'https://basket-2401.wbbasket.ru/vol2401/part240122/240122176/images/c516x688/1.webp';
const actual240 = getImageUrl('240122176');
console.log('Проверка артикула 240122176:');
console.log('Ожидается:', expected240);
console.log('Получено:', actual240.imageUrl);
console.log('Совпадает:', expected240 === actual240.imageUrl);

File diff suppressed because one or more lines are too long