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",
|
"name": "openparsersferav",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.9.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"@tailwindcss/postcss": "^4.1.7",
|
"@tailwindcss/postcss": "^4.1.7",
|
||||||
"anychart": "^8.13.1",
|
"anychart": "^8.13.1",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
|
"chart.js": "^4.5.0",
|
||||||
"cheerio": "^1.0.0-rc.12",
|
"cheerio": "^1.0.0-rc.12",
|
||||||
"framer-motion": "^12.12.1",
|
"framer-motion": "^12.12.1",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"next": "15.3.0",
|
"next": "15.3.0",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"prisma": "^5.9.0",
|
"prisma": "^5.22.0",
|
||||||
"puppeteer": "^24.8.2",
|
"puppeteer": "^24.8.2",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
|
"react-chartjs-2": "^5.3.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"recharts": "^2.15.3",
|
"recharts": "^2.15.3",
|
||||||
@ -660,6 +662,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "0.2.10",
|
"version": "0.2.10",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.10.tgz",
|
"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"
|
"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": {
|
"node_modules/cheerio": {
|
||||||
"version": "1.0.0-rc.12",
|
"version": "1.0.0-rc.12",
|
||||||
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz",
|
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz",
|
||||||
@ -6320,6 +6340,16 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.1.0",
|
"version": "19.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||||
|
@ -14,6 +14,7 @@
|
|||||||
"@tailwindcss/postcss": "^4.1.7",
|
"@tailwindcss/postcss": "^4.1.7",
|
||||||
"anychart": "^8.13.1",
|
"anychart": "^8.13.1",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
|
"chart.js": "^4.5.0",
|
||||||
"cheerio": "^1.0.0-rc.12",
|
"cheerio": "^1.0.0-rc.12",
|
||||||
"framer-motion": "^12.12.1",
|
"framer-motion": "^12.12.1",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
@ -22,6 +23,7 @@
|
|||||||
"prisma": "^5.9.0",
|
"prisma": "^5.9.0",
|
||||||
"puppeteer": "^24.8.2",
|
"puppeteer": "^24.8.2",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
|
"react-chartjs-2": "^5.3.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"recharts": "^2.15.3",
|
"recharts": "^2.15.3",
|
||||||
|
@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
output = "../src/generated/prisma"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
|
@ -148,7 +148,7 @@ const config = {
|
|||||||
"value": "prisma-client-js"
|
"value": "prisma-client-js"
|
||||||
},
|
},
|
||||||
"output": {
|
"output": {
|
||||||
"value": "/Users/alexander/qa/scan-sphere/src/generated/prisma",
|
"value": "/Users/alexander/qa/scan-sphere/scan-sphera-main/src/generated/prisma",
|
||||||
"fromEnvVar": null
|
"fromEnvVar": null
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
@ -162,7 +162,7 @@ const config = {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"previewFeatures": [],
|
"previewFeatures": [],
|
||||||
"sourceFilePath": "/Users/alexander/qa/scan-sphere/prisma/schema.prisma",
|
"sourceFilePath": "/Users/alexander/qa/scan-sphere/scan-sphera-main/prisma/schema.prisma",
|
||||||
"isCustomOutput": true
|
"isCustomOutput": true
|
||||||
},
|
},
|
||||||
"relativeEnvPaths": {
|
"relativeEnvPaths": {
|
||||||
@ -176,6 +176,7 @@ const config = {
|
|||||||
"db"
|
"db"
|
||||||
],
|
],
|
||||||
"activeProvider": "postgresql",
|
"activeProvider": "postgresql",
|
||||||
|
"postinstall": true,
|
||||||
"inlineDatasources": {
|
"inlineDatasources": {
|
||||||
"db": {
|
"db": {
|
||||||
"url": {
|
"url": {
|
||||||
|
@ -149,7 +149,7 @@ const config = {
|
|||||||
"value": "prisma-client-js"
|
"value": "prisma-client-js"
|
||||||
},
|
},
|
||||||
"output": {
|
"output": {
|
||||||
"value": "/Users/alexander/qa/scan-sphere/src/generated/prisma",
|
"value": "/Users/alexander/qa/scan-sphere/scan-sphera-main/src/generated/prisma",
|
||||||
"fromEnvVar": null
|
"fromEnvVar": null
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
@ -163,7 +163,7 @@ const config = {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"previewFeatures": [],
|
"previewFeatures": [],
|
||||||
"sourceFilePath": "/Users/alexander/qa/scan-sphere/prisma/schema.prisma",
|
"sourceFilePath": "/Users/alexander/qa/scan-sphere/scan-sphera-main/prisma/schema.prisma",
|
||||||
"isCustomOutput": true
|
"isCustomOutput": true
|
||||||
},
|
},
|
||||||
"relativeEnvPaths": {
|
"relativeEnvPaths": {
|
||||||
@ -177,6 +177,7 @@ const config = {
|
|||||||
"db"
|
"db"
|
||||||
],
|
],
|
||||||
"activeProvider": "postgresql",
|
"activeProvider": "postgresql",
|
||||||
|
"postinstall": true,
|
||||||
"inlineDatasources": {
|
"inlineDatasources": {
|
||||||
"db": {
|
"db": {
|
||||||
"url": {
|
"url": {
|
||||||
|
@ -172,8 +172,8 @@ export async function POST(req: NextRequest) {
|
|||||||
.filter(p => p.product.article === myArticleId)
|
.filter(p => p.product.article === myArticleId)
|
||||||
.map(p => {
|
.map(p => {
|
||||||
// Вычисляем позицию на странице (примерно 30 товаров на страницу)
|
// Вычисляем позицию на странице (примерно 30 товаров на страницу)
|
||||||
const page = p.page || Math.ceil((p.position || 1) / 30);
|
const page = p.page || Math.ceil((p.position || 1) / 100);
|
||||||
const positionOnPage = p.position ? ((p.position - 1) % 30) + 1 : null;
|
const positionOnPage = p.position ? ((p.position - 1) % 100) + 1 : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
city: p.city,
|
city: p.city,
|
||||||
@ -188,8 +188,8 @@ export async function POST(req: NextRequest) {
|
|||||||
positions[competitorArticleId] = existingQuery.positions
|
positions[competitorArticleId] = existingQuery.positions
|
||||||
.filter(p => p.product.article === competitorArticleId)
|
.filter(p => p.product.article === competitorArticleId)
|
||||||
.map(p => {
|
.map(p => {
|
||||||
const page = p.page || Math.ceil((p.position || 1) / 30);
|
const page = p.page || Math.ceil((p.position || 1) / 100);
|
||||||
const positionOnPage = p.position ? ((p.position - 1) % 30) + 1 : null;
|
const positionOnPage = p.position ? ((p.position - 1) % 100) + 1 : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
city: p.city,
|
city: p.city,
|
||||||
@ -278,7 +278,7 @@ export async function POST(req: NextRequest) {
|
|||||||
const position = posData.position;
|
const position = posData.position;
|
||||||
|
|
||||||
if (position && position > 0) {
|
if (position && position > 0) {
|
||||||
const page = Math.ceil(position / 30);
|
const page = Math.ceil(position / 100);
|
||||||
|
|
||||||
await prisma.position.create({
|
await prisma.position.create({
|
||||||
data: {
|
data: {
|
||||||
|
@ -21,6 +21,12 @@ interface ParseResult {
|
|||||||
positions: { [articleId: string]: CityPosition[] };
|
positions: { [articleId: string]: CityPosition[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Результат поиска позиций + данные карточек из листинга
|
||||||
|
interface SearchPositionsResult {
|
||||||
|
positions: { [articleId: string]: number | null };
|
||||||
|
listingDetails: { [articleId: string]: Partial<ProductData> & { position?: number | null } };
|
||||||
|
}
|
||||||
|
|
||||||
const cities = [
|
const cities = [
|
||||||
{ name: 'Москва', code: 'msk' },
|
{ name: 'Москва', code: 'msk' },
|
||||||
{ name: 'Санкт-Петербург', code: 'spb' },
|
{ name: 'Санкт-Петербург', code: 'spb' },
|
||||||
@ -124,6 +130,12 @@ async function createStealthBrowser() {
|
|||||||
|
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
|
|
||||||
|
// Повышаем таймауты: WB может показывать антибот-экран
|
||||||
|
try {
|
||||||
|
page.setDefaultNavigationTimeout(90000);
|
||||||
|
page.setDefaultTimeout(90000);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
// Устанавливаем случайный User-Agent
|
// Устанавливаем случайный User-Agent
|
||||||
const userAgents = [
|
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',
|
'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, {
|
await page.goto(url, {
|
||||||
waitUntil: 'domcontentloaded',
|
waitUntil: 'domcontentloaded',
|
||||||
timeout: 45000
|
timeout: 60000
|
||||||
});
|
});
|
||||||
|
|
||||||
// Дополнительное ожидание для полной загрузки
|
// Дополнительное ожидание для полной загрузки
|
||||||
await randomDelay(2000, 4000);
|
await randomDelay(2500, 6000);
|
||||||
|
|
||||||
// Проверяем, что страница загрузилась корректно
|
// Проверяем, что страница загрузилась корректно
|
||||||
const currentUrl = page.url();
|
const currentUrl = page.url();
|
||||||
@ -229,13 +241,31 @@ async function robustNavigation(page: any, url: string, maxRetries: number = 3):
|
|||||||
return false;
|
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;
|
return true;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(` ⚠️ Ошибка навигации попытка ${attempt}:`, (error as Error).message);
|
console.log(` ⚠️ Ошибка навигации попытка ${attempt}:`, (error as Error).message);
|
||||||
|
|
||||||
if (attempt < maxRetries) {
|
if (attempt < maxRetries) {
|
||||||
const delay = attempt * 3000 + Math.random() * 2000; // Увеличивающаяся задержка
|
const delay = attempt * 4000 + Math.random() * 3000; // Увеличивающаяся задержка
|
||||||
console.log(` ⏳ Ожидание ${Math.round(delay/1000)}с перед следующей попыткой...`);
|
console.log(` ⏳ Ожидание ${Math.round(delay/1000)}с перед следующей попыткой...`);
|
||||||
await new Promise(resolve => setTimeout(resolve, delay));
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
}
|
}
|
||||||
@ -540,18 +570,13 @@ async function searchPositions(
|
|||||||
cityCode: string,
|
cityCode: string,
|
||||||
enhancedScraping: boolean = false,
|
enhancedScraping: boolean = false,
|
||||||
maxItems: number = 2000
|
maxItems: number = 2000
|
||||||
): Promise<{ [articleId: string]: number | null }> {
|
): Promise<SearchPositionsResult> {
|
||||||
console.log(`Парсинг результатов поиска для города ${cityCode === 'msk' ? 'Москва' : cityCode.toUpperCase()} (${cityCode})...`);
|
console.log(`Парсинг результатов поиска для города ${cityCode === 'msk' ? 'Москва' : cityCode.toUpperCase()} (${cityCode})...`);
|
||||||
|
|
||||||
// Сначала пробуем альтернативные API методы (более надежные)
|
// Получаем предварительные позиции через API как фолбэк
|
||||||
const apiPositions = await tryAlternativeSearch(query, targetArticles);
|
const apiPositions = await tryAlternativeSearch(query, targetArticles);
|
||||||
const foundPositions = Object.values(apiPositions).filter(pos => pos !== null);
|
const apiFound = Object.values(apiPositions).filter(Boolean).length;
|
||||||
if (foundPositions.length > 0) {
|
console.log(`ℹ️ Предварительные позиции API: ${apiFound}/${targetArticles.length}. Переходим к каталогу для точного ранка...`);
|
||||||
console.log(`✅ Артикулы найдены через API, пропускаем браузерный парсинг`);
|
|
||||||
return apiPositions;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('⚠️ API поиск не дал результатов, переходим к браузерному парсингу...');
|
|
||||||
|
|
||||||
let browser, page;
|
let browser, page;
|
||||||
|
|
||||||
@ -571,20 +596,7 @@ async function searchPositions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Устанавливаем обработчики для мониторинга AJAX-запросов (опционально)
|
// Устанавливаем обработчики для мониторинга 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();
|
const currentUrl = page.url();
|
||||||
@ -704,15 +716,22 @@ async function searchPositions(
|
|||||||
console.log(`${hasCatalogLinks ? '✅' : '❌'} Ссылки /catalog/ найдены: ${hasCatalogLinks}`);
|
console.log(`${hasCatalogLinks ? '✅' : '❌'} Ссылки /catalog/ найдены: ${hasCatalogLinks}`);
|
||||||
console.log(`${hasSearchQuery ? '✅' : '❌'} Поисковый запрос в HTML: ${hasSearchQuery}`);
|
console.log(`${hasSearchQuery ? '✅' : '❌'} Поисковый запрос в HTML: ${hasSearchQuery}`);
|
||||||
|
|
||||||
return positions;
|
// Извлекаем данные карточек из уже загруженного листинга для целевых артикулов
|
||||||
|
const listingDetails = await extractListingDataForArticlesOnPage(page, targetArticles);
|
||||||
|
|
||||||
|
return { positions, listingDetails };
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при расширенном парсинге позиций:', error);
|
console.error('Ошибка при расширенном парсинге позиций:', error);
|
||||||
const emptyResult: { [articleId: string]: number | null } = {};
|
// Возвращаем хотя бы API-позиции, если они были
|
||||||
targetArticles.forEach(article => {
|
if (apiFound > 0) {
|
||||||
emptyResult[article] = null;
|
console.log('↩️ Возвращаем предварительные позиции из API из-за ошибки браузерного парсинга.');
|
||||||
});
|
const listingDetails: { [articleId: string]: Partial<ProductData> } = {};
|
||||||
return emptyResult;
|
return { positions: apiPositions, listingDetails };
|
||||||
|
}
|
||||||
|
const emptyPositions: { [articleId: string]: number | null } = {};
|
||||||
|
targetArticles.forEach(article => emptyPositions[article] = null);
|
||||||
|
return { positions: emptyPositions, listingDetails: {} };
|
||||||
} finally {
|
} finally {
|
||||||
if (browser) {
|
if (browser) {
|
||||||
await browser.close();
|
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> {
|
async function getProductData(article: string): Promise<ProductData | null> {
|
||||||
console.log(`Загрузка данных о товаре: https://www.wildberries.ru/catalog/${article}/detail.aspx`);
|
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 {
|
try {
|
||||||
const browserData = await createStealthBrowser();
|
const browser = await puppeteer.launch({
|
||||||
browser = browserData.browser;
|
headless: true,
|
||||||
page = browserData.page;
|
args: [
|
||||||
|
'--no-sandbox',
|
||||||
await page.goto(`https://www.wildberries.ru/catalog/${article}/detail.aspx`, {
|
'--disable-setuid-sandbox',
|
||||||
waitUntil: 'networkidle2',
|
'--disable-dev-shm-usage',
|
||||||
timeout: 30000
|
'--disable-accelerated-2d-canvas',
|
||||||
|
'--no-first-run',
|
||||||
|
'--no-zygote',
|
||||||
|
'--disable-gpu',
|
||||||
|
],
|
||||||
|
timeout: 30000,
|
||||||
});
|
});
|
||||||
|
|
||||||
await randomDelay(2000, 4000);
|
const page = await browser.newPage();
|
||||||
|
|
||||||
const selectors = {
|
// Ускоряем загрузку страницы блокировкой ненужных ресурсов
|
||||||
name: '.product-page__header h1, .product-page__title, [data-link="text{:productName}"]',
|
await page.setRequestInterception(true);
|
||||||
brand: '.product-page__header-brand, .product-page__brand, [data-link="text{:brandName}"]',
|
page.on('request', (req) => {
|
||||||
price: '.price-block__final-price, .price__lower-price, [data-link="text{:priceFormatter(price)}"]',
|
const resourceType = req.resourceType();
|
||||||
image: '.preview__list img, .swiper-slide img, [data-link="src{:imageSrc}"]'
|
if (
|
||||||
};
|
resourceType === 'stylesheet' ||
|
||||||
|
resourceType === 'font' ||
|
||||||
|
resourceType === 'media' ||
|
||||||
|
req.url().includes('yandex') ||
|
||||||
|
req.url().includes('google') ||
|
||||||
|
req.url().includes('analytics')
|
||||||
|
) {
|
||||||
|
req.abort();
|
||||||
|
} else {
|
||||||
|
req.continue();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let name = '', brand = '', price = 0, imageUrl = '';
|
// Устанавливаем 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'
|
||||||
|
);
|
||||||
|
|
||||||
for (const [key, selector] of Object.entries(selectors)) {
|
// Открываем страницу товара
|
||||||
try {
|
const url = `https://www.wildberries.ru/catalog/${article}/detail.aspx`;
|
||||||
const element = await page.$(selector);
|
|
||||||
if (element) {
|
try {
|
||||||
if (key === 'image') {
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
||||||
imageUrl = await element.evaluate((img: Element) => {
|
} catch (e) {
|
||||||
const htmlImg = img as HTMLImageElement;
|
console.log(`Ошибка загрузки страницы: ${e}. Пробуем альтернативный способ.`);
|
||||||
return htmlImg.src || htmlImg.getAttribute('data-src') || '';
|
// Альтернативный подход в случае ошибки
|
||||||
});
|
await Promise.race([
|
||||||
} else {
|
page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 }),
|
||||||
const text = await element.evaluate((el: Element) => el.textContent?.trim() || '');
|
new Promise(resolve => setTimeout(resolve, 15000))
|
||||||
if (key === 'name') name = text;
|
]);
|
||||||
else if (key === 'brand') brand = text;
|
}
|
||||||
else if (key === 'price') {
|
|
||||||
// Убираем все пробелы и символы валют, оставляем только цифры
|
// Ждем, пока не загрузится основная информация
|
||||||
const cleanText = text.replace(/[^\d]/g, '');
|
await page
|
||||||
if (cleanText) price = parseInt(cleanText);
|
.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();
|
||||||
|
|
||||||
|
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) {
|
if (!price) {
|
||||||
price = Math.floor(Math.random() * 1000) + 100;
|
price = Math.floor(Math.random() * 300) + 200;
|
||||||
console.log(`Не удалось получить цену для ${article}, использована случайная: ${price} ₽`);
|
console.log(`Не удалось получить цену для ${article}, использована случайная: ${price} ₽`);
|
||||||
}
|
}
|
||||||
if (!imageUrl) {
|
|
||||||
// Пытаемся сгенерировать URL изображения на основе артикула
|
// Получаем бренд с расширенным набором селекторов
|
||||||
const vol = Math.floor(parseInt(article) / 100000);
|
const brand = $('.product-page__brand-link').text().trim() ||
|
||||||
const part = Math.floor(parseInt(article) / 1000);
|
$('.product-card__brand').text().trim() ||
|
||||||
imageUrl = `https://basket-${vol.toString().padStart(2, '0')}.wbbasket.ru/vol${vol}/part${part}/${article}/images/c516x688/1.webp`;
|
$('.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}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (imageUrl) {
|
await browser.close();
|
||||||
console.log(`Получено изображение: ${imageUrl}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
// Очищаем название от лишних символов
|
||||||
|
name = name.replace(/"/g, '"').replace(/&/g, '&').replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
const result = {
|
||||||
name,
|
name,
|
||||||
brand,
|
brand,
|
||||||
price,
|
price,
|
||||||
@ -799,13 +1196,17 @@ async function getProductData(article: string): Promise<ProductData | null> {
|
|||||||
imageUrl
|
imageUrl
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log(`✅ Получены данные товара ${article}:`);
|
||||||
|
console.log(` 📦 Название: ${name}`);
|
||||||
|
console.log(` 🏷️ Бренд: ${brand}`);
|
||||||
|
console.log(` 💰 Цена: ${price}₽`);
|
||||||
|
console.log(` 🖼️ Изображение: ${imageUrl}`);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при получении данных товара:', error);
|
console.error('Ошибка при получении данных товара:', error);
|
||||||
return null;
|
return generateFallbackProductData(article);
|
||||||
} finally {
|
|
||||||
if (browser) {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -817,9 +1218,15 @@ function generateFallbackProductData(article: string): ProductData {
|
|||||||
const productTypes = ['Товар', 'Продукт', 'Изделие'];
|
const productTypes = ['Товар', 'Продукт', 'Изделие'];
|
||||||
const brands = ['Unknown Brand', 'NoName', 'Generic'];
|
const brands = ['Unknown Brand', 'NoName', 'Generic'];
|
||||||
|
|
||||||
const vol = Math.floor(parseInt(article) / 100000);
|
// Корректный расчет vol и part для URL изображения
|
||||||
const part = Math.floor(parseInt(article) / 1000);
|
const articleNum = parseInt(article);
|
||||||
const imageUrl = `https://basket-${vol.toString().padStart(2, '0')}.wbbasket.ru/vol${vol}/part${part}/${article}/images/c516x688/1.webp`;
|
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 {
|
return {
|
||||||
name: `${productTypes[parseInt(article) % productTypes.length]} ${article}`,
|
name: `${productTypes[parseInt(article) % productTypes.length]} ${article}`,
|
||||||
@ -847,7 +1254,8 @@ export async function parseWildberries(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. Получаем позиции для Москвы (как основного города)
|
// 1. Получаем позиции для Москвы (как основного города)
|
||||||
const moscowPositions = await searchPositions(query, targetArticles, 'msk', enhancedScraping, maxItems);
|
const moscow = await searchPositions(query, targetArticles, 'msk', enhancedScraping, maxItems);
|
||||||
|
const moscowPositions = moscow.positions;
|
||||||
|
|
||||||
// 2. Собираем позиции в требуемом формате для всех городов
|
// 2. Собираем позиции в требуемом формате для всех городов
|
||||||
const positions: { [articleId: string]: CityPosition[] } = {};
|
const positions: { [articleId: string]: CityPosition[] } = {};
|
||||||
@ -860,8 +1268,8 @@ export async function parseWildberries(
|
|||||||
// Добавляем данные для Москвы
|
// Добавляем данные для Москвы
|
||||||
targetArticles.forEach(article => {
|
targetArticles.forEach(article => {
|
||||||
const position = moscowPositions[article];
|
const position = moscowPositions[article];
|
||||||
const page = position ? Math.ceil(position / 30) : null;
|
const page = position ? Math.ceil(position / 100) : null;
|
||||||
const positionOnPage = position ? ((position - 1) % 30) + 1 : null;
|
const positionOnPage = position ? ((position - 1) % 100) + 1 : null;
|
||||||
|
|
||||||
positions[article].push({
|
positions[article].push({
|
||||||
city: 'Москва',
|
city: 'Москва',
|
||||||
@ -875,20 +1283,43 @@ export async function parseWildberries(
|
|||||||
for (const city of cities.slice(1)) {
|
for (const city of cities.slice(1)) {
|
||||||
console.log(`Генерация данных для города ${city.name} (${city.code})...`);
|
console.log(`Генерация данных для города ${city.name} (${city.code})...`);
|
||||||
|
|
||||||
|
const cityPositions: { [article: string]: number | null } = {};
|
||||||
|
|
||||||
|
// Сначала генерируем позиции для всех артикулов
|
||||||
targetArticles.forEach(article => {
|
targetArticles.forEach(article => {
|
||||||
const moscowPosition = moscowPositions[article];
|
const moscowPosition = moscowPositions[article];
|
||||||
let generatedPosition = null;
|
let generatedPosition = null;
|
||||||
let generatedPage = null;
|
|
||||||
let generatedPositionOnPage = null;
|
|
||||||
|
|
||||||
if (moscowPosition) {
|
if (moscowPosition) {
|
||||||
// Генерируем позицию в пределах ±5 от московской
|
// Генерируем позицию в пределах ±5 от московской
|
||||||
const variance = Math.floor(Math.random() * 11) - 5; // от -5 до +5
|
const variance = Math.floor(Math.random() * 11) - 5; // от -5 до +5
|
||||||
generatedPosition = Math.max(1, moscowPosition + variance);
|
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({
|
positions[article].push({
|
||||||
city: city.name,
|
city: city.name,
|
||||||
position: generatedPosition,
|
position: generatedPosition,
|
||||||
@ -901,8 +1332,34 @@ export async function parseWildberries(
|
|||||||
// 3. Получаем данные о каждом товаре
|
// 3. Получаем данные о каждом товаре
|
||||||
const products: ProductData[] = [];
|
const products: ProductData[] = [];
|
||||||
for (const article of targetArticles) {
|
for (const article of targetArticles) {
|
||||||
console.log(`Получаем данные о товаре ${article}...`);
|
console.log(`Получаем данные о товаре ${article} (поиск через каталог)...`);
|
||||||
const data = await getProductData(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) {
|
if (data) {
|
||||||
products.push(data);
|
products.push(data);
|
||||||
console.log(`✅ Получены данные товара ${article}: ${data.name} - ${data.price}₽`);
|
console.log(`✅ Получены данные товара ${article}: ${data.name} - ${data.price}₽`);
|
||||||
@ -918,3 +1375,376 @@ export async function parseWildberries(
|
|||||||
positions
|
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 React, { useEffect, useRef } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { FiBarChart, FiActivity } from 'react-icons/fi';
|
import { FiActivity } from 'react-icons/fi';
|
||||||
|
|
||||||
interface ChartDataPoint {
|
interface ChartDataPoint {
|
||||||
city: string;
|
city: string;
|
||||||
@ -25,13 +25,11 @@ const PositionChart: React.FC<PositionChartProps> = ({
|
|||||||
const chartRef = useRef<any>(null);
|
const chartRef = useRef<any>(null);
|
||||||
const scriptsLoadedRef = useRef<boolean>(false);
|
const scriptsLoadedRef = useRef<boolean>(false);
|
||||||
|
|
||||||
// Загружаем скрипты AnyChart
|
// Загружаем скрипты AnyChart (строго по порядку)
|
||||||
const loadAnyChartScripts = () => {
|
const loadAnyChartScripts = () => {
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>(async (resolve, reject) => {
|
||||||
if (scriptsLoadedRef.current && window.anychart) {
|
if (typeof window === 'undefined') return resolve();
|
||||||
resolve();
|
if (scriptsLoadedRef.current && (window as any).anychart) return resolve();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const scripts = [
|
const scripts = [
|
||||||
'https://cdn.anychart.com/releases/v8/js/anychart-base.min.js',
|
'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'
|
'https://cdn.anychart.com/releases/v8/js/anychart-data-adapter.min.js'
|
||||||
];
|
];
|
||||||
|
|
||||||
let loadedCount = 0;
|
const loadScript = (src: string) => new Promise<void>((res, rej) => {
|
||||||
|
// Не дублируем
|
||||||
scripts.forEach((src) => {
|
const existing = document.querySelector(`script[src="${src}"]`) as HTMLScriptElement | null;
|
||||||
const script = document.createElement('script');
|
if (existing && existing.getAttribute('data-loaded') === 'true') return res();
|
||||||
|
const script = existing || document.createElement('script');
|
||||||
script.src = src;
|
script.src = src;
|
||||||
script.onload = () => {
|
script.async = false; // важен порядок
|
||||||
loadedCount++;
|
script.onload = () => { script.setAttribute('data-loaded', 'true'); res(); };
|
||||||
if (loadedCount === scripts.length) {
|
script.onerror = () => rej(new Error(`Failed to load script: ${src}`));
|
||||||
scriptsLoadedRef.current = true;
|
if (!existing) document.head.appendChild(script);
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
script.onerror = () => reject(new Error(`Failed to load script: ${src}`));
|
|
||||||
document.head.appendChild(script);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Загружаем CSS
|
try {
|
||||||
const link = document.createElement('link');
|
for (const src of scripts) {
|
||||||
link.href = 'https://cdn.anychart.com/releases/v8/css/anychart-ui.min.css';
|
// eslint-disable-next-line no-await-in-loop
|
||||||
link.type = 'text/css';
|
await loadScript(src);
|
||||||
link.rel = 'stylesheet';
|
}
|
||||||
document.head.appendChild(link);
|
// 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');
|
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.href = 'https://cdn.anychart.com/releases/v8/fonts/css/anychart-font.min.css';
|
||||||
fontLink.type = 'text/css';
|
fontLink.type = 'text/css';
|
||||||
fontLink.rel = 'stylesheet';
|
fontLink.rel = 'stylesheet';
|
||||||
document.head.appendChild(fontLink);
|
if (!fontLink.parentElement) document.head.appendChild(fontLink);
|
||||||
|
|
||||||
|
scriptsLoadedRef.current = true;
|
||||||
|
resolve();
|
||||||
|
} catch (err) {
|
||||||
|
reject(err as Error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Преобразуем данные для Box and Whisker диаграммы с исправлением одинаковых значений
|
// Преобразование данных для column chart с цветовой логикой (один столбец = средняя позиция)
|
||||||
const prepareBoxData = (data: ChartDataPoint[]) => {
|
const prepareColumnData = (points: ChartDataPoint[]) => {
|
||||||
if (!data || data.length === 0) {
|
if (!points || points.length === 0) return [];
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.map((item) => {
|
return points.map((item) => {
|
||||||
// Собираем все позиции (исключаем нулевые и отрицательные)
|
const myPos = item.myPosition > 0 ? item.myPosition : null;
|
||||||
const positions = [item.myPosition, item.competitorPosition].filter(p => p > 0);
|
const compPos = item.competitorPosition > 0 ? item.competitorPosition : null;
|
||||||
|
|
||||||
if (positions.length === 0) {
|
// Вычисляем среднюю позицию
|
||||||
// Если нет валидных позиций, создаем небольшой видимый бокс на позиции 1
|
const positions = [myPos, compPos].filter(p => p !== null) as number[];
|
||||||
const basePos = 1;
|
let averagePosition = null;
|
||||||
const boxWidth = 0.6;
|
let color = '#8b5cf6'; // Purple default
|
||||||
return {
|
|
||||||
x: item.city,
|
|
||||||
low: basePos - boxWidth / 2,
|
|
||||||
q1: basePos - boxWidth / 4,
|
|
||||||
median: basePos,
|
|
||||||
q3: basePos + boxWidth / 4,
|
|
||||||
high: basePos + boxWidth / 2,
|
|
||||||
outliers: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (positions.length === 1) {
|
if (positions.length > 0) {
|
||||||
// Если только одна позиция, создаем небольшой видимый бокс
|
averagePosition = positions.reduce((a, b) => a + b, 0) / positions.length;
|
||||||
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 (myPos && compPos) {
|
||||||
|
if (myPos < compPos) {
|
||||||
// Если позиции одинаковые, добавляем небольшое смещение для визуализации
|
color = '#22c55e'; // Green - мой лучше
|
||||||
if (pos1 === pos2) {
|
} else if (myPos > compPos) {
|
||||||
pos2 = pos1 + 0.5; // Увеличиваем смещение для видимости
|
color = '#ef4444'; // Red - конкурент лучше
|
||||||
}
|
}
|
||||||
|
// Удалили amber цвет для одинаковых позиций, так как это невозможно в реальном каталоге
|
||||||
const min = Math.min(pos1, pos2);
|
} else if (myPos) {
|
||||||
const max = Math.max(pos1, pos2);
|
color = '#8b5cf6'; // Purple - только мой
|
||||||
const median = (pos1 + pos2) / 2;
|
} else if (compPos) {
|
||||||
|
color = '#a855f7'; // Light purple - только конкурент
|
||||||
// Создаем искусственные квартили для видимого бокса
|
}
|
||||||
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 {
|
return {
|
||||||
x: item.city,
|
x: item.city,
|
||||||
low: min,
|
value: averagePosition,
|
||||||
q1: adjustedQ1,
|
fill: color,
|
||||||
median: median,
|
myPosition: myPos,
|
||||||
q3: adjustedQ3,
|
competitorPosition: compPos
|
||||||
high: max,
|
|
||||||
outliers: []
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Создание и настройка графика
|
// Создание/обновление графика
|
||||||
const createChart = async () => {
|
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 {
|
try {
|
||||||
// Удаляем предыдущий график
|
|
||||||
if (chartRef.current) {
|
if (chartRef.current) {
|
||||||
chartRef.current.dispose();
|
chartRef.current.dispose();
|
||||||
chartRef.current = null;
|
chartRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Подготавливаем данные
|
const columnData = prepareColumnData(data);
|
||||||
const boxData = prepareBoxData(data);
|
if (columnData.length === 0) return;
|
||||||
|
|
||||||
if (boxData.length === 0) return;
|
const chart = (window as any).anychart.column();
|
||||||
|
|
||||||
// Создаем Box диаграмму
|
|
||||||
const chart = window.anychart.box();
|
|
||||||
|
|
||||||
// Настраиваем заголовок
|
|
||||||
const title = chart.title('Сравнение позиций товаров по городам');
|
const title = chart.title('Сравнение позиций товаров по городам');
|
||||||
if (title && typeof title === 'object' && 'fontColor' in title) {
|
if (title && typeof title === 'object' && 'fontColor' in title) {
|
||||||
(title as any).fontColor('#7c3aed');
|
(title as any).fontColor('#7c3aed');
|
||||||
@ -178,116 +141,114 @@ const PositionChart: React.FC<PositionChartProps> = ({
|
|||||||
(title as any).fontWeight(600);
|
(title as any).fontWeight(600);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Настраиваем оси
|
chart.yAxis().title('Средняя позиция');
|
||||||
chart.yAxis().title('Позиция');
|
|
||||||
chart.yAxis().labels().format('{%value}');
|
chart.yAxis().labels().format('{%value}');
|
||||||
try {
|
try { (chart.yAxis().labels() as any).fontColor('#6b7280'); } catch {}
|
||||||
(chart.yAxis().labels() as any).fontColor('#6b7280');
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Не удалось настроить цвет меток Y:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
chart.xAxis().title('Города');
|
chart.xAxis().title('Города');
|
||||||
chart.xAxis().staggerMode(true);
|
chart.xAxis().staggerMode(true);
|
||||||
try {
|
try { (chart.xAxis().labels() as any).fontColor('#6b7280'); } catch {}
|
||||||
(chart.xAxis().labels() as any).fontColor('#6b7280');
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Не удалось настроить цвет меток X:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Настраиваем шкалу Y (инвертируем для позиций - 1 сверху)
|
// Инвертируем ось Y (меньшая позиция = лучше = выше на графике)
|
||||||
try {
|
try {
|
||||||
const yScale = (chart as any).yScale();
|
const yScale = (chart as any).yScale();
|
||||||
if (yScale && typeof yScale.inverted === 'function') {
|
if (yScale && typeof yScale.inverted === 'function') yScale.inverted(true);
|
||||||
yScale.inverted(true);
|
} catch {}
|
||||||
|
|
||||||
|
// Создаем единственную серию для средних позиций
|
||||||
|
const series = chart.column(columnData);
|
||||||
|
series.name('Средняя позиция');
|
||||||
|
|
||||||
|
// Настраиваем цвет для каждого столбца индивидуально
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.log('Не удалось инвертировать ось Y:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Создаем серию с простым фиолетовым стилем
|
return tooltip;
|
||||||
const series = (chart as any).box(boxData);
|
});
|
||||||
|
|
||||||
// Настраиваем внешний вид с purple theme
|
// Настройка сетки
|
||||||
try {
|
|
||||||
series.whiskerWidth('60%');
|
|
||||||
series.fill('#8b5cf6', 0.4);
|
|
||||||
series.stroke('#7c3aed', 2);
|
|
||||||
series.whiskerStroke('#7c3aed', 2);
|
|
||||||
series.medianStroke('#6d28d9', 3);
|
|
||||||
|
|
||||||
// Настраиваем подсказки
|
|
||||||
series.tooltip().format(
|
|
||||||
'Город: {%x}' +
|
|
||||||
'\nМин позиция: {%low}' +
|
|
||||||
'\nМакс позиция: {%high}' +
|
|
||||||
'\nМедиана: {%median}'
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Ошибка при настройке серии:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Настраиваем сетку
|
|
||||||
try {
|
try {
|
||||||
(chart as any).grid(0).stroke('#e5e7eb', 1, '2 2');
|
(chart as any).grid(0).stroke('#e5e7eb', 1, '2 2');
|
||||||
(chart as any).grid(1).layout('vertical').stroke('#e5e7eb', 1, '2 2');
|
(chart as any).grid(1).layout('vertical').stroke('#e5e7eb', 1, '2 2');
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.log('Не удалось настроить сетку:', error);
|
|
||||||
try {
|
try {
|
||||||
(chart as any).yGrid(true);
|
(chart as any).yGrid(true);
|
||||||
(chart as any).xGrid(true);
|
(chart as any).xGrid(true);
|
||||||
} catch (gridError) {
|
} catch {}
|
||||||
console.log('Альтернативная настройка сетки также недоступна:', gridError);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Настраиваем фон
|
// Легенда
|
||||||
(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).container(chartContainerRef.current);
|
||||||
(chart as any).draw();
|
(chart as any).draw();
|
||||||
|
|
||||||
chartRef.current = chart;
|
chartRef.current = chart;
|
||||||
|
} catch (e) {
|
||||||
} catch (error) {
|
console.error('Ошибка при создании графика:', e);
|
||||||
console.error('Ошибка при создании графика:', error);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Инициализация компонента
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initChart = async () => {
|
const init = async () => {
|
||||||
try {
|
try {
|
||||||
await loadAnyChartScripts();
|
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();
|
await createChart();
|
||||||
} catch (error) {
|
} catch (e) {
|
||||||
console.error('Ошибка при инициализации графика:', error);
|
console.error('Ошибка инициализации графика:', e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
init();
|
||||||
initChart();
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (chartRef.current) {
|
if (chartRef.current) {
|
||||||
chartRef.current.dispose();
|
chartRef.current.dispose();
|
||||||
chartRef.current = null;
|
chartRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Обновление данных
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scriptsLoadedRef.current && window.anychart) {
|
const update = async () => {
|
||||||
createChart();
|
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]);
|
}, [data, myArticleId, competitorArticleId]);
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
return (
|
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">
|
<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
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
@ -306,8 +267,6 @@ const PositionChart: React.FC<PositionChartProps> = ({
|
|||||||
|
|
||||||
return (
|
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">
|
<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
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
@ -321,24 +280,27 @@ const PositionChart: React.FC<PositionChartProps> = ({
|
|||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Minimal Legend */}
|
{/* Минимальная легенда */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ delay: 0.5 }}
|
transition={{ delay: 0.5 }}
|
||||||
className="flex-shrink-0 mt-3 pt-3 border-t border-white/20"
|
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="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>
|
<div className="w-3 h-2 bg-green-500 rounded-sm"></div>
|
||||||
<span>{myArticleId}</span>
|
<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>
|
</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>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,16 +1,15 @@
|
|||||||
import { PrismaClient } from '../../generated/prisma';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
// PrismaClient является тяжелым для инстанцирования,
|
// PrismaClient является тяжелым для инстанцирования,
|
||||||
// поэтому мы глобально сохраняем одиночный экземпляр
|
// поэтому мы глобально сохраняем одиночный экземпляр
|
||||||
declare global {
|
const globalForPrisma = globalThis as unknown as {
|
||||||
// eslint-disable-next-line no-var
|
prisma: PrismaClient | undefined;
|
||||||
var 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') {
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||||
global.prisma = prisma;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default prisma;
|
export default prisma;
|
||||||
|
@ -92,8 +92,7 @@ export default function Home() {
|
|||||||
// Обновляем графические данные
|
// Обновляем графические данные
|
||||||
const chartDataFromHistory = historyItem.positions.map((pos) => ({
|
const chartDataFromHistory = historyItem.positions.map((pos) => ({
|
||||||
city: pos.city,
|
city: pos.city,
|
||||||
myPosition:
|
myPosition: pos.pageRank === 1 ? pos.rank : (pos.pageRank - 1) * 100 + pos.rank,
|
||||||
pos.pageRank === 1 ? pos.rank : (pos.pageRank - 1) * 100 + pos.rank,
|
|
||||||
// В истории нет данных конкурента, используем заглушку
|
// В истории нет данных конкурента, используем заглушку
|
||||||
competitorPosition:
|
competitorPosition:
|
||||||
pos.competitorRank && pos.competitorPageRank
|
pos.competitorRank && pos.competitorPageRank
|
||||||
|
@ -176,6 +176,7 @@ const config = {
|
|||||||
"db"
|
"db"
|
||||||
],
|
],
|
||||||
"activeProvider": "postgresql",
|
"activeProvider": "postgresql",
|
||||||
|
"postinstall": false,
|
||||||
"inlineDatasources": {
|
"inlineDatasources": {
|
||||||
"db": {
|
"db": {
|
||||||
"url": {
|
"url": {
|
||||||
|
@ -177,6 +177,7 @@ const config = {
|
|||||||
"db"
|
"db"
|
||||||
],
|
],
|
||||||
"activeProvider": "postgresql",
|
"activeProvider": "postgresql",
|
||||||
|
"postinstall": false,
|
||||||
"inlineDatasources": {
|
"inlineDatasources": {
|
||||||
"db": {
|
"db": {
|
||||||
"url": {
|
"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