graphic remake as well as parcer rework
This commit is contained in:
34
package-lock.json
generated
34
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -6,7 +6,6 @@
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
output = "../src/generated/prisma"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
|
@ -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": {
|
||||
|
@ -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": {
|
||||
|
@ -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: {
|
||||
|
@ -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(/"/g, '"').replace(/&/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(/"/g, '"').replace(/&/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;
|
||||
}
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -176,6 +176,7 @@ const config = {
|
||||
"db"
|
||||
],
|
||||
"activeProvider": "postgresql",
|
||||
"postinstall": false,
|
||||
"inlineDatasources": {
|
||||
"db": {
|
||||
"url": {
|
||||
|
@ -177,6 +177,7 @@ const config = {
|
||||
"db"
|
||||
],
|
||||
"activeProvider": "postgresql",
|
||||
"postinstall": false,
|
||||
"inlineDatasources": {
|
||||
"db": {
|
||||
"url": {
|
||||
|
38
test-basket-logic.js
Normal file
38
test-basket-logic.js
Normal 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
59
test-image-urls.js
Normal 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);
|
4844
wb-parser-debug-enhanced.html
Normal file
4844
wb-parser-debug-enhanced.html
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user