new commit
This commit is contained in:
40
scan-sphera-main/src/app/ClientWrapper.tsx
Normal file
40
scan-sphera-main/src/app/ClientWrapper.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Preloader } from './components';
|
||||
|
||||
interface ClientWrapperProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ClientWrapper: React.FC<ClientWrapperProps> = ({ children }) => {
|
||||
const [preloaderDone, setPreloaderDone] = useState(false);
|
||||
const [showContent, setShowContent] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (preloaderDone) {
|
||||
// Небольшая задержка перед показом контента для плавного перехода
|
||||
const timer = setTimeout(() => {
|
||||
setShowContent(true);
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [preloaderDone]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Preloader onComplete={() => setPreloaderDone(true)} />
|
||||
|
||||
<div
|
||||
className={`transition-opacity duration-500 ${
|
||||
showContent ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientWrapper;
|
97
scan-sphera-main/src/app/api/health/route.ts
Normal file
97
scan-sphera-main/src/app/api/health/route.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import prisma from '../../lib/prisma';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// API endpoint для проверки работоспособности приложения и БД
|
||||
export async function GET(req: NextRequest) {
|
||||
let dbStatus = 'ok';
|
||||
let staticFilesStatus = 'ok';
|
||||
let cacheStatus = 'ok';
|
||||
const errors = [];
|
||||
|
||||
try {
|
||||
// Проверка подключения к базе данных
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
} catch (error) {
|
||||
console.error('Ошибка проверки подключения к БД:', error);
|
||||
dbStatus = 'error';
|
||||
errors.push(`Ошибка БД: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
// Проверка доступа к статическим файлам
|
||||
try {
|
||||
const logoPngPath = path.join(process.cwd(), 'public', 'images', 'logo.png');
|
||||
const logoSvgPath = path.join(process.cwd(), 'public', 'images', 'logo.svg');
|
||||
const noImagePath = path.join(process.cwd(), 'public', 'images', 'no-image.svg');
|
||||
const faviconPath = path.join(process.cwd(), 'public', 'favicon.ico');
|
||||
|
||||
// Проверяем наличие хотя бы одного из вариантов логотипа (PNG или SVG)
|
||||
const hasLogo = fs.existsSync(logoPngPath) || fs.existsSync(logoSvgPath);
|
||||
|
||||
if (hasLogo && fs.existsSync(noImagePath)) {
|
||||
staticFilesStatus = 'ok';
|
||||
} else {
|
||||
staticFilesStatus = 'warning';
|
||||
const missingFiles = [];
|
||||
if (!hasLogo) missingFiles.push('logo.png/logo.svg');
|
||||
if (!fs.existsSync(noImagePath)) missingFiles.push('no-image.svg');
|
||||
if (!fs.existsSync(faviconPath)) missingFiles.push('favicon.ico');
|
||||
|
||||
console.warn(`Отсутствуют некоторые статические файлы: ${missingFiles.join(', ')}`);
|
||||
errors.push(`Отсутствуют файлы: ${missingFiles.join(', ')}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при проверке статических файлов:', error);
|
||||
staticFilesStatus = 'error';
|
||||
errors.push(`Ошибка статических файлов: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
// Проверка доступа к кэшу изображений
|
||||
try {
|
||||
const cachePath = path.join(process.cwd(), 'public', 'images', 'cache');
|
||||
|
||||
if (fs.existsSync(cachePath) && fs.statSync(cachePath).isDirectory()) {
|
||||
// Попытка создать временный файл для проверки прав записи
|
||||
const testFile = path.join(cachePath, `test-${Date.now()}.txt`);
|
||||
fs.writeFileSync(testFile, 'test');
|
||||
fs.unlinkSync(testFile);
|
||||
} else {
|
||||
cacheStatus = 'error';
|
||||
console.error('Отсутствует директория кэша или нет прав на запись');
|
||||
errors.push('Отсутствует директория кэша или нет прав на запись');
|
||||
|
||||
// Попытка создать директорию кэша
|
||||
try {
|
||||
fs.mkdirSync(cachePath, { recursive: true });
|
||||
fs.chmodSync(cachePath, 0o777);
|
||||
console.log('Создана директория кэша');
|
||||
cacheStatus = 'ok';
|
||||
} catch (mkdirError) {
|
||||
console.error('Не удалось создать директорию кэша:', mkdirError);
|
||||
errors.push(`Не удалось создать директорию кэша: ${mkdirError instanceof Error ? mkdirError.message : String(mkdirError)}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при проверке директории кэша:', error);
|
||||
cacheStatus = 'error';
|
||||
errors.push(`Ошибка кэша: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
// Определяем общий статус на основе проверок
|
||||
const overallStatus =
|
||||
dbStatus === 'error' ? 'error' :
|
||||
staticFilesStatus === 'error' || cacheStatus === 'error' ? 'error' :
|
||||
staticFilesStatus === 'warning' || cacheStatus === 'warning' ? 'warning' : 'ok';
|
||||
|
||||
return NextResponse.json({
|
||||
status: overallStatus,
|
||||
timestamp: new Date().toISOString(),
|
||||
details: {
|
||||
database: dbStatus,
|
||||
staticFiles: staticFilesStatus,
|
||||
cache: cacheStatus,
|
||||
errors: errors.length > 0 ? errors : undefined
|
||||
}
|
||||
});
|
||||
}
|
227
scan-sphera-main/src/app/api/history/route.ts
Normal file
227
scan-sphera-main/src/app/api/history/route.ts
Normal file
@ -0,0 +1,227 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { HistoryRecord } from '@/types';
|
||||
import prisma from '../../lib/prisma';
|
||||
import { CITIES } from '@/types';
|
||||
|
||||
// Функция для форматирования даты в формат ДД.ММ.ГГГГ
|
||||
function formatDate(date: Date): string {
|
||||
return date.toLocaleDateString('ru-RU');
|
||||
}
|
||||
|
||||
// GET запрос для получения истории поисков
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Получение параметров запроса
|
||||
const { searchParams } = new URL(request.url);
|
||||
const articleId = searchParams.get('articleId');
|
||||
const limit = searchParams.get('limit')
|
||||
? parseInt(searchParams.get('limit') as string)
|
||||
: 10; // Увеличиваем лимит для лучшего отображения истории
|
||||
const query = searchParams.get('query'); // Добавляем параметр фильтрации по поисковому запросу
|
||||
|
||||
try {
|
||||
// Получаем данные из БД через Prisma
|
||||
const searchQueries = await prisma.searchQuery.findMany({
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: limit * 2, // Получаем больше записей, чтобы после фильтрации осталось достаточно
|
||||
include: {
|
||||
products: true,
|
||||
positions: {
|
||||
include: {
|
||||
product: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Фильтруем запросы
|
||||
let filteredQueries = searchQueries;
|
||||
|
||||
// Фильтруем по артикулу, если указан
|
||||
if (articleId) {
|
||||
filteredQueries = filteredQueries.filter((q) =>
|
||||
q.products.some((product) => product.article === articleId)
|
||||
);
|
||||
}
|
||||
|
||||
// Фильтруем по поисковому запросу, если указан
|
||||
if (query) {
|
||||
filteredQueries = filteredQueries.filter(
|
||||
(q) => q.query.toLowerCase() === query.toLowerCase()
|
||||
);
|
||||
|
||||
// Если указан и артикул, и запрос - строго фильтруем по обоим параметрам
|
||||
if (articleId) {
|
||||
// Получаем самый последний запрос с этими параметрами
|
||||
filteredQueries = filteredQueries.slice(0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Сортируем по дате создания (сначала новые)
|
||||
filteredQueries = filteredQueries.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
|
||||
// Ограничиваем количество результатов
|
||||
filteredQueries = filteredQueries.slice(0, limit);
|
||||
|
||||
// Преобразуем данные в формат HistoryRecord
|
||||
const historyRecords: HistoryRecord[] = filteredQueries.map((query) => {
|
||||
// Находим товары
|
||||
const myProduct = query.products.find((p) => !p.isCompetitor);
|
||||
const competitorProduct = query.products.find((p) => p.isCompetitor);
|
||||
|
||||
// Формируем список позиций
|
||||
const positions = CITIES.map((city) => {
|
||||
// Находим позиции для текущего города (мои)
|
||||
const myPositionData = query.positions.find(
|
||||
(p) => p.city === city && p.product.isCompetitor === false
|
||||
);
|
||||
|
||||
// Находим позиции конкурента для текущего города
|
||||
const competitorPositionData = query.positions.find(
|
||||
(p) => p.city === city && p.product.isCompetitor === true
|
||||
);
|
||||
|
||||
// Определяем позицию на странице и номер страницы для моих позиций
|
||||
const position = myPositionData?.position || 0;
|
||||
const page = myPositionData?.page || 0;
|
||||
// Вычисляем позицию на странице (примерно 30 товаров на страницу)
|
||||
const rank = position ? ((position - 1) % 30) + 1 : 0;
|
||||
const pageRank = page || Math.ceil(position / 30);
|
||||
|
||||
// Определяем позицию на странице и номер страницы для позиций конкурента
|
||||
const competitorPosition = competitorPositionData?.position || 0;
|
||||
const competitorPage = competitorPositionData?.page || 0;
|
||||
const competitorRank = competitorPosition ? ((competitorPosition - 1) % 30) + 1 : 0;
|
||||
const competitorPageRank = competitorPage || Math.ceil(competitorPosition / 30);
|
||||
|
||||
return {
|
||||
city,
|
||||
rank,
|
||||
pageRank,
|
||||
competitorRank: competitorRank > 0 ? competitorRank : undefined,
|
||||
competitorPageRank:
|
||||
competitorPageRank > 0 ? competitorPageRank : undefined,
|
||||
};
|
||||
}).filter((pos) => pos.rank > 0); // Оставляем только найденные позиции
|
||||
|
||||
return {
|
||||
id: query.id.toString(),
|
||||
date: formatDate(query.createdAt),
|
||||
query: query.query,
|
||||
myArticleId: myProduct?.article || '',
|
||||
competitorArticleId: competitorProduct?.article || '',
|
||||
positions,
|
||||
hasCompetitor: !!competitorProduct?.article, // Добавляем флаг наличия конкурента
|
||||
};
|
||||
});
|
||||
|
||||
if (historyRecords.length === 0) {
|
||||
// Если нет данных, возвращаем пустой массив
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
|
||||
return NextResponse.json(historyRecords);
|
||||
} catch (dbError) {
|
||||
console.error('Ошибка при запросе к БД:', dbError);
|
||||
// В случае ошибки БД возвращаем пустой массив
|
||||
return NextResponse.json(
|
||||
{ error: 'Ошибка при получении данных из БД' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении истории:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Произошла ошибка при обработке запроса' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST запрос для создания новой записи в истории
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// Проверка обязательных полей
|
||||
if (
|
||||
!body.query ||
|
||||
!body.myArticleId ||
|
||||
!body.competitorArticleId ||
|
||||
!body.positions
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Не указаны все обязательные поля' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Сохранение в базу данных через Prisma
|
||||
const newQuery = await prisma.searchQuery.create({
|
||||
data: {
|
||||
query: body.query,
|
||||
products: {
|
||||
create: [
|
||||
{ article: body.myArticleId, isCompetitor: false },
|
||||
{ article: body.competitorArticleId, isCompetitor: true },
|
||||
],
|
||||
},
|
||||
positions: {
|
||||
createMany: {
|
||||
data: body.positions.map(
|
||||
(pos: {
|
||||
city: string;
|
||||
rank: number;
|
||||
pageRank: number;
|
||||
isCompetitor?: boolean;
|
||||
}) => ({
|
||||
city: pos.city,
|
||||
position: pos.rank,
|
||||
page: pos.pageRank,
|
||||
productId: pos.isCompetitor
|
||||
? body.competitorArticleId
|
||||
: body.myArticleId,
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
products: true,
|
||||
positions: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Формируем ответ
|
||||
const response: HistoryRecord = {
|
||||
id: newQuery.id.toString(),
|
||||
date: formatDate(newQuery.createdAt),
|
||||
query: newQuery.query,
|
||||
myArticleId: body.myArticleId,
|
||||
competitorArticleId: body.competitorArticleId,
|
||||
positions: body.positions,
|
||||
hasCompetitor: !!body.competitorArticleId, // Добавляем флаг наличия конкурента
|
||||
};
|
||||
|
||||
return NextResponse.json(response, { status: 201 });
|
||||
} catch (dbError) {
|
||||
console.error('Ошибка при сохранении в БД:', dbError);
|
||||
return NextResponse.json(
|
||||
{ error: 'Ошибка при сохранении данных в базу' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при создании записи в истории:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Произошла ошибка при обработке запроса' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
367
scan-sphera-main/src/app/api/parser/route.ts
Normal file
367
scan-sphera-main/src/app/api/parser/route.ts
Normal file
@ -0,0 +1,367 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { parseWildberries } from '../utils/wbParser';
|
||||
import { saveSearchHistory } from '../../lib/history';
|
||||
import prisma from '../../lib/prisma';
|
||||
import { SearchResponse, CityPosition } from '@/types';
|
||||
|
||||
// Увеличиваем таймаут для API роута
|
||||
export const maxDuration = 300; // 300 секунд = 5 минут
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// Функция для расчета реальной позиции с учетом страницы
|
||||
const calculatePosition = (
|
||||
position: number | string,
|
||||
page: number | string
|
||||
): number => {
|
||||
// Преобразуем позицию в число
|
||||
const posNum =
|
||||
typeof position === 'number'
|
||||
? position
|
||||
: typeof position === 'string'
|
||||
? parseInt(position, 10)
|
||||
: 0;
|
||||
|
||||
if (posNum <= 0) return 0;
|
||||
|
||||
// Преобразуем страницу в число
|
||||
const pageNum =
|
||||
typeof page === 'number'
|
||||
? page
|
||||
: typeof page === 'string'
|
||||
? parseInt(page, 10)
|
||||
: 1;
|
||||
|
||||
if (isNaN(pageNum)) return posNum;
|
||||
|
||||
// На первой странице позиция как есть, иначе (страница-1)*100 + позиция
|
||||
return pageNum === 1 ? posNum : (pageNum - 1) * 100 + posNum;
|
||||
};
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { query, myArticleId, competitorArticleId, maxItems = 100 } = await req.json();
|
||||
|
||||
// Проверяем наличие обязательных параметров
|
||||
if (!query || !myArticleId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Не указаны все обязательные поля (запрос и артикул)' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Логируем запрос
|
||||
console.log(
|
||||
`Получен запрос на парсинг: ${query}, артикулы: ${myArticleId}, ${
|
||||
competitorArticleId || 'без конкурента'
|
||||
}, максимум товаров: ${maxItems}`
|
||||
);
|
||||
|
||||
try {
|
||||
// Проверяем, есть ли уже результаты за сегодня
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
// Ищем последний запрос с теми же параметрами за сегодня
|
||||
const existingQuery = await prisma.searchQuery.findFirst({
|
||||
where: {
|
||||
query: query,
|
||||
createdAt: {
|
||||
gte: today,
|
||||
lt: tomorrow,
|
||||
},
|
||||
products: {
|
||||
some: {
|
||||
article: myArticleId,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
products: true,
|
||||
positions: {
|
||||
include: {
|
||||
product: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
// Если запрос найден, проверяем совпадение указанного конкурента с запрошенным
|
||||
let useExistingQuery = false;
|
||||
|
||||
if (existingQuery) {
|
||||
// Проверяем совпадение конкурента или его отсутствие
|
||||
const hasCompetitorInExistingQuery = existingQuery.products.some(
|
||||
(p) => p.isCompetitor && p.article === competitorArticleId
|
||||
);
|
||||
|
||||
const requestHasCompetitor = !!competitorArticleId;
|
||||
const storedHasCompetitor = existingQuery.products.some(
|
||||
(p) => p.isCompetitor
|
||||
);
|
||||
|
||||
// Используем кэш только если запрашиваемый режим совпадает с сохраненным
|
||||
// (либо оба с конкурентом и артикулы совпадают, либо оба без конкурента)
|
||||
if (
|
||||
(requestHasCompetitor &&
|
||||
storedHasCompetitor &&
|
||||
hasCompetitorInExistingQuery) ||
|
||||
(!requestHasCompetitor && !storedHasCompetitor)
|
||||
) {
|
||||
useExistingQuery = true;
|
||||
console.log(
|
||||
`Найден кэшированный запрос от ${existingQuery.createdAt.toLocaleTimeString()}, возвращаем данные из БД`
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`Найден кэшированный запрос, но режим (${
|
||||
requestHasCompetitor ? 'с' : 'без'
|
||||
} конкурента) не совпадает с запрошенным. Выполняем новый запрос.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Если запрос найден и режим совпадает, формируем и возвращаем результаты из базы данных
|
||||
if (existingQuery && useExistingQuery) {
|
||||
const myProduct = existingQuery.products.find(
|
||||
(p) => p.article === myArticleId
|
||||
);
|
||||
const competitorProduct = competitorArticleId
|
||||
? existingQuery.products.find(
|
||||
(p) => p.article === competitorArticleId
|
||||
)
|
||||
: null;
|
||||
|
||||
// Если мой товар найден
|
||||
if (myProduct) {
|
||||
// Сохраняем историю запроса в локальное хранилище
|
||||
saveSearchHistory(query, myArticleId, competitorArticleId);
|
||||
|
||||
// Формируем список товаров
|
||||
const products = [
|
||||
{
|
||||
name: myProduct.title || 'Неизвестно',
|
||||
brand: 'Из БД',
|
||||
price: myProduct.price || 0,
|
||||
article: myArticleId,
|
||||
imageUrl: myProduct.imageUrl || '/images/no-image.svg',
|
||||
}
|
||||
];
|
||||
|
||||
// Добавляем товар конкурента, если есть
|
||||
if (competitorProduct) {
|
||||
products.push({
|
||||
name: competitorProduct.title || 'Неизвестно',
|
||||
brand: 'Из БД',
|
||||
price: competitorProduct.price || 0,
|
||||
article: competitorArticleId || '',
|
||||
imageUrl: competitorProduct.imageUrl || '/images/no-image.svg',
|
||||
});
|
||||
}
|
||||
|
||||
// Формируем позиции для каждого товара
|
||||
const positions: { [articleId: string]: CityPosition[] } = {};
|
||||
|
||||
// Позиции основного товара
|
||||
positions[myArticleId] = existingQuery.positions
|
||||
.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;
|
||||
|
||||
return {
|
||||
city: p.city,
|
||||
position: p.position,
|
||||
page: page,
|
||||
positionOnPage: positionOnPage
|
||||
};
|
||||
});
|
||||
|
||||
// Позиции товара конкурента, если есть
|
||||
if (competitorArticleId && competitorProduct) {
|
||||
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;
|
||||
|
||||
return {
|
||||
city: p.city,
|
||||
position: p.position,
|
||||
page: page,
|
||||
positionOnPage: positionOnPage
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Формируем ответ в новом формате
|
||||
const responseData: SearchResponse = {
|
||||
products: products,
|
||||
positions: positions,
|
||||
myArticleId: myArticleId,
|
||||
competitorArticleId: competitorArticleId,
|
||||
};
|
||||
|
||||
// Возвращаем результаты из БД
|
||||
return NextResponse.json(responseData);
|
||||
}
|
||||
}
|
||||
|
||||
// Если в кэше нет данных, делаем новый запрос к Wildberries
|
||||
console.log('Кэшированных данных нет, выполняем парсинг Wildberries');
|
||||
|
||||
// Устанавливаем таймаут для ответа API (всегда используем увеличенный таймаут)
|
||||
const apiTimeout = setTimeout(() => {
|
||||
console.log('Предупреждение: API запрос выполняется дольше ожидаемого, но продолжает работу');
|
||||
}, 90000); // 90 секунд для поиска больших объемов товаров
|
||||
|
||||
try {
|
||||
// Выполняем парсинг с расширенными возможностями
|
||||
const result = await parseWildberries(
|
||||
query,
|
||||
myArticleId,
|
||||
competitorArticleId,
|
||||
true, // Всегда используем расширенный парсинг
|
||||
maxItems
|
||||
);
|
||||
|
||||
// Очищаем таймаут
|
||||
clearTimeout(apiTimeout);
|
||||
|
||||
// Сохраняем историю запроса в локальное хранилище
|
||||
saveSearchHistory(query, myArticleId, competitorArticleId);
|
||||
|
||||
// Сохраняем данные в PostgreSQL
|
||||
try {
|
||||
// Создаем запись о поисковом запросе
|
||||
const searchQuery = await prisma.searchQuery.create({
|
||||
data: {
|
||||
query: query,
|
||||
},
|
||||
});
|
||||
|
||||
// Сохраняем данные о всех товарах
|
||||
for (const product of result.products) {
|
||||
await prisma.product.upsert({
|
||||
where: { article: product.article },
|
||||
update: {
|
||||
title: product.name,
|
||||
price: product.price,
|
||||
imageUrl: product.imageUrl,
|
||||
searchQueryId: searchQuery.id,
|
||||
},
|
||||
create: {
|
||||
article: product.article,
|
||||
title: product.name,
|
||||
price: product.price,
|
||||
imageUrl: product.imageUrl,
|
||||
isCompetitor: product.article !== myArticleId,
|
||||
searchQueryId: searchQuery.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Сохраняем данные о позициях для всех товаров
|
||||
for (const [articleId, cityPositions] of Object.entries(result.positions)) {
|
||||
const productDB = await prisma.product.findUnique({
|
||||
where: { article: articleId },
|
||||
});
|
||||
|
||||
if (productDB && cityPositions.length > 0) {
|
||||
for (const posData of cityPositions) {
|
||||
const position = posData.position;
|
||||
|
||||
if (position && position > 0) {
|
||||
const page = Math.ceil(position / 30);
|
||||
|
||||
await prisma.position.create({
|
||||
data: {
|
||||
city: posData.city,
|
||||
position: position,
|
||||
page: page,
|
||||
productId: productDB.id,
|
||||
searchQueryId: searchQuery.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Данные успешно сохранены в базе данных`);
|
||||
} catch (dbError) {
|
||||
console.error('Ошибка при сохранении в базу данных:', dbError);
|
||||
// Продолжаем выполнение, даже если сохранение в БД не удалось
|
||||
}
|
||||
|
||||
// Проверяем структуру результата перед возвратом
|
||||
if (!result || !result.products || result.products.length === 0) {
|
||||
console.error('Парсер вернул пустой результат');
|
||||
const fallbackResponse: SearchResponse = {
|
||||
products: [{
|
||||
name: 'Ошибка при получении данных',
|
||||
brand: 'Н/Д',
|
||||
price: 0,
|
||||
article: myArticleId,
|
||||
imageUrl: '/images/no-image.svg',
|
||||
}],
|
||||
positions: { [myArticleId]: [] },
|
||||
myArticleId: myArticleId,
|
||||
competitorArticleId: competitorArticleId,
|
||||
};
|
||||
return NextResponse.json(fallbackResponse);
|
||||
}
|
||||
|
||||
// Формируем ответ в новом формате
|
||||
const responseData: SearchResponse = {
|
||||
products: result.products,
|
||||
positions: result.positions,
|
||||
myArticleId: myArticleId,
|
||||
competitorArticleId: competitorArticleId,
|
||||
};
|
||||
|
||||
// Возвращаем результат
|
||||
return NextResponse.json(responseData);
|
||||
} catch (parsingError) {
|
||||
// Очищаем таймаут
|
||||
clearTimeout(apiTimeout);
|
||||
|
||||
console.error('Ошибка при парсинге:', parsingError);
|
||||
|
||||
// Создаем базовый ответ с сообщением об ошибке в новом формате
|
||||
const errorResponse: SearchResponse = {
|
||||
products: [{
|
||||
name: 'Ошибка при получении данных',
|
||||
brand: 'Н/Д',
|
||||
price: 0,
|
||||
article: myArticleId,
|
||||
imageUrl: '/images/no-image.svg',
|
||||
}],
|
||||
positions: { [myArticleId]: [] },
|
||||
myArticleId: myArticleId,
|
||||
competitorArticleId: competitorArticleId,
|
||||
};
|
||||
|
||||
return NextResponse.json(errorResponse, { status: 500 });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при парсинге:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Ошибка при парсинге данных', details: String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при обработке запроса:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Внутренняя ошибка сервера', details: String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
37
scan-sphera-main/src/app/api/test-parser/route.ts
Normal file
37
scan-sphera-main/src/app/api/test-parser/route.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { parseWildberries } from '../utils/wbParser';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { query, myArticleId, competitorArticleId, maxItems = 100 } = await request.json();
|
||||
|
||||
if (!query || !myArticleId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Требуются параметры query и myArticleId' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Тестовый парсинг: ${query}, артикул: ${myArticleId}, максимум товаров: ${maxItems}`);
|
||||
|
||||
// Запускаем парсинг без сохранения в БД (всегда с расширенными возможностями)
|
||||
const result = await parseWildberries(query, myArticleId, competitorArticleId, true, maxItems);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: `Парсинг выполнен успешно (тестовый режим) - проанализировано до ${maxItems} товаров`
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ошибка тестового парсинга:', error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Ошибка при парсинге',
|
||||
details: error instanceof Error ? error.message : 'Неизвестная ошибка'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
1004
scan-sphera-main/src/app/api/utils/wbParser.ts
Normal file
1004
scan-sphera-main/src/app/api/utils/wbParser.ts
Normal file
@ -0,0 +1,1004 @@
|
||||
import * as cheerio from 'cheerio';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
interface ProductData {
|
||||
name: string;
|
||||
brand: string;
|
||||
price: number;
|
||||
article: string;
|
||||
imageUrl: string;
|
||||
}
|
||||
|
||||
interface CityPosition {
|
||||
city: string;
|
||||
position: number | null;
|
||||
page?: number | null;
|
||||
positionOnPage?: number | null;
|
||||
}
|
||||
|
||||
interface ParseResult {
|
||||
products: ProductData[];
|
||||
positions: { [articleId: string]: CityPosition[] };
|
||||
}
|
||||
|
||||
const cities = [
|
||||
{ name: 'Москва', code: 'msk' },
|
||||
{ name: 'Санкт-Петербург', code: 'spb' },
|
||||
{ name: 'Казань', code: 'kzn' },
|
||||
{ name: 'Екатеринбург', code: 'ekb' },
|
||||
{ name: 'Новосибирск', code: 'nsk' },
|
||||
{ name: 'Краснодар', code: 'krd' },
|
||||
{ name: 'Хабаровск', code: 'khv' }
|
||||
];
|
||||
|
||||
// Функция для случайной задержки
|
||||
const randomDelay = (min: number, max: number) =>
|
||||
new Promise(resolve => setTimeout(resolve, Math.random() * (max - min) + min));
|
||||
|
||||
// Поиск позиций всех артикулов в HTML
|
||||
function findMultipleArticlePositions(html: string, targetArticles: string[]): { [articleId: string]: number | null } {
|
||||
console.log(`Поиск артикулов в HTML:`);
|
||||
|
||||
// Поиск всех артикулов в разных форматах
|
||||
const patterns = [
|
||||
/data-nm-id="(\d+)"/g,
|
||||
/\/catalog\/(\d+)\/detail\.aspx/g,
|
||||
/"nmId":(\d+)/g,
|
||||
/"id":(\d+)/g
|
||||
];
|
||||
|
||||
const foundArticles: string[] = [];
|
||||
const articlePositions: { [articleId: string]: number | null } = {};
|
||||
|
||||
// Инициализируем результат
|
||||
targetArticles.forEach(article => {
|
||||
articlePositions[article] = null;
|
||||
});
|
||||
|
||||
patterns.forEach(pattern => {
|
||||
let match;
|
||||
while ((match = pattern.exec(html)) !== null) {
|
||||
const article = match[1];
|
||||
if (!foundArticles.includes(article)) {
|
||||
foundArticles.push(article);
|
||||
|
||||
// Проверяем, есть ли этот артикул среди искомых
|
||||
if (targetArticles.includes(article)) {
|
||||
articlePositions[article] = foundArticles.length;
|
||||
console.log(` - Артикул ${article}: ✅ НАЙДЕН на позиции ${foundArticles.length}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(` - Всего найдено уникальных артикулов: ${foundArticles.length}`);
|
||||
console.log(` - Первые 10 артикулов:`, foundArticles.slice(0, 10));
|
||||
|
||||
// Логируем результаты для всех искомых артикулов
|
||||
targetArticles.forEach(article => {
|
||||
if (articlePositions[article] === null) {
|
||||
console.log(` - Артикул ${article}: ❌ НЕ НАЙДЕН`);
|
||||
}
|
||||
});
|
||||
|
||||
return articlePositions;
|
||||
}
|
||||
|
||||
// Улучшенная функция создания браузера с максимальной маскировкой
|
||||
async function createStealthBrowser() {
|
||||
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',
|
||||
'--disable-web-security',
|
||||
'--disable-features=VizDisplayCompositor',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--disable-ipc-flooding-protection',
|
||||
'--disable-background-networking',
|
||||
'--disable-default-apps',
|
||||
'--disable-extensions',
|
||||
'--disable-sync',
|
||||
'--disable-translate',
|
||||
'--hide-scrollbars',
|
||||
'--metrics-recording-only',
|
||||
'--mute-audio',
|
||||
'--no-default-browser-check',
|
||||
'--no-pings',
|
||||
'--password-store=basic',
|
||||
'--use-mock-keychain',
|
||||
'--disable-component-extensions-with-background-pages',
|
||||
'--disable-permissions-api',
|
||||
'--disable-notifications',
|
||||
'--user-agent=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'
|
||||
]
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Устанавливаем случайный 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',
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (X11; Linux x86_64) 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/119.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36'
|
||||
];
|
||||
|
||||
await page.setUserAgent(userAgents[Math.floor(Math.random() * userAgents.length)]);
|
||||
|
||||
// Устанавливаем viewport
|
||||
await page.setViewport({
|
||||
width: 1920 + Math.floor(Math.random() * 100),
|
||||
height: 1080 + Math.floor(Math.random() * 100)
|
||||
});
|
||||
|
||||
// Скрываем автоматизацию
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
// Удаляем webdriver property
|
||||
delete (navigator as any).webdriver;
|
||||
|
||||
// Переопределяем plugins
|
||||
Object.defineProperty(navigator, 'plugins', {
|
||||
get: () => [1, 2, 3, 4, 5],
|
||||
});
|
||||
|
||||
// Переопределяем languages
|
||||
Object.defineProperty(navigator, 'languages', {
|
||||
get: () => ['ru-RU', 'ru', 'en-US', 'en'],
|
||||
});
|
||||
|
||||
// Переопределяем permission API
|
||||
const originalQuery = window.navigator.permissions.query;
|
||||
window.navigator.permissions.query = (parameters) => (
|
||||
parameters.name === 'notifications' ?
|
||||
Promise.resolve({ state: 'default' as PermissionState } as PermissionStatus) :
|
||||
originalQuery(parameters)
|
||||
);
|
||||
|
||||
// Мокаем chrome runtime
|
||||
(window as any).chrome = {
|
||||
runtime: {
|
||||
onConnect: null,
|
||||
onMessage: null,
|
||||
},
|
||||
};
|
||||
|
||||
// Скрываем автоматизацию в navigator
|
||||
Object.defineProperty(navigator, 'webdriver', {
|
||||
get: () => undefined,
|
||||
});
|
||||
});
|
||||
|
||||
// Устанавливаем дополнительные заголовки
|
||||
await page.setExtraHTTPHeaders({
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||
'Accept-Language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'DNT': '1',
|
||||
'Connection': 'keep-alive',
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
'Sec-Fetch-Dest': 'document',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-Site': 'none',
|
||||
'Sec-Fetch-User': '?1',
|
||||
'Cache-Control': 'max-age=0',
|
||||
'sec-ch-ua': '"Google Chrome";v="120", "Chromium";v="120", "Not?A_Brand";v="24"',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': '"macOS"'
|
||||
});
|
||||
|
||||
return { browser, page };
|
||||
}
|
||||
|
||||
// Функция для надежной навигации с повторными попытками
|
||||
async function robustNavigation(page: any, url: string, maxRetries: number = 3): Promise<boolean> {
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
console.log(` 🔄 Попытка навигации ${attempt}/${maxRetries}: ${url.substring(0, 80)}...`);
|
||||
|
||||
await page.goto(url, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 45000
|
||||
});
|
||||
|
||||
// Дополнительное ожидание для полной загрузки
|
||||
await randomDelay(2000, 4000);
|
||||
|
||||
// Проверяем, что страница загрузилась корректно
|
||||
const currentUrl = page.url();
|
||||
const title = await page.title();
|
||||
|
||||
console.log(` ✅ Навигация успешна: ${currentUrl.substring(0, 50)}... | Заголовок: ${title.substring(0, 30)}...`);
|
||||
|
||||
// Проверяем, что не попали на страницу блокировки
|
||||
if (title.includes('Access denied') || title.includes('Доступ запрещен') || currentUrl.includes('blocked')) {
|
||||
console.log(` ❌ Страница заблокирована, попытка ${attempt}`);
|
||||
if (attempt < maxRetries) {
|
||||
await randomDelay(5000, 10000); // Увеличенная пауза
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.log(` ⚠️ Ошибка навигации попытка ${attempt}:`, (error as Error).message);
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
const delay = attempt * 3000 + Math.random() * 2000; // Увеличивающаяся задержка
|
||||
console.log(` ⏳ Ожидание ${Math.round(delay/1000)}с перед следующей попыткой...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Функция для имитации человеческого поведения
|
||||
async function simulateHumanBehavior(page: any) {
|
||||
// Случайное движение мыши
|
||||
await page.mouse.move(
|
||||
Math.random() * 1920,
|
||||
Math.random() * 1080
|
||||
);
|
||||
|
||||
await randomDelay(500, 1500);
|
||||
|
||||
// Случайная прокрутка
|
||||
await page.evaluate(() => {
|
||||
window.scrollTo(0, Math.random() * 200);
|
||||
});
|
||||
|
||||
await randomDelay(300, 800);
|
||||
}
|
||||
|
||||
// Альтернативный поиск через мобильную версию или API
|
||||
async function tryAlternativeSearch(query: string, targetArticles: string[]): Promise<{ [articleId: string]: number | null }> {
|
||||
console.log('🔄 Пробуем альтернативные методы поиска...');
|
||||
|
||||
try {
|
||||
// Мобильная версия API Wildberries
|
||||
const mobileApiUrls = [
|
||||
`https://search.wb.ru/exactmatch/ru/common/v4/search?appType=1&curr=rub&dest=-1257786&query=${encodeURIComponent(query)}&resultset=catalog&sort=popular&spp=0&suppressSpellcheck=false&page=1`,
|
||||
`https://search.wb.ru/exactmatch/ru/common/v5/search?appType=1&curr=rub&dest=-1257786&query=${encodeURIComponent(query)}&resultset=catalog&sort=popular&spp=0&suppressSpellcheck=false&page=1`,
|
||||
`https://catalog.wb.ru/search?appType=1&curr=rub&dest=-1257786&query=${encodeURIComponent(query)}&sort=popular&spp=0&page=1`
|
||||
];
|
||||
|
||||
for (const apiUrl of mobileApiUrls) {
|
||||
try {
|
||||
console.log(` 🌐 Пробуем API: ${apiUrl.substring(0, 60)}...`);
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
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',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log(` 📊 API ответ получен, товаров в data: ${data?.data?.products?.length || 0}`);
|
||||
|
||||
if (data?.data?.products && Array.isArray(data.data.products)) {
|
||||
const positions: { [articleId: string]: number | null } = {};
|
||||
targetArticles.forEach(article => positions[article] = null);
|
||||
|
||||
data.data.products.forEach((product: any, index: number) => {
|
||||
const productId = product.id?.toString() || product.nmId?.toString() || '';
|
||||
if (targetArticles.includes(productId)) {
|
||||
positions[productId] = index + 1;
|
||||
console.log(` ✅ Найден артикул ${productId} на позиции ${index + 1} через API`);
|
||||
}
|
||||
});
|
||||
|
||||
const foundCount = Object.values(positions).filter(pos => pos !== null).length;
|
||||
if (foundCount > 0) {
|
||||
console.log(` 🎯 Найдено ${foundCount}/${targetArticles.length} артикулов через API`);
|
||||
return positions;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (apiError) {
|
||||
console.log(` ❌ API недоступен:`, (apiError as Error).message);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Ошибка альтернативного поиска:', (error as Error).message);
|
||||
}
|
||||
|
||||
// Возвращаем пустой результат
|
||||
const emptyResult: { [articleId: string]: number | null } = {};
|
||||
targetArticles.forEach(article => emptyResult[article] = null);
|
||||
return emptyResult;
|
||||
}
|
||||
|
||||
// Улучшенная функция прокрутки для загрузки больше товаров
|
||||
async function scrollToLoadMoreProducts(page: any, maxItems: number = 100): Promise<number> {
|
||||
console.log(`🔄 Начинаем прокрутку для загрузки до ${maxItems} товаров...`);
|
||||
|
||||
let previousCount = 0;
|
||||
let currentCount = 0;
|
||||
let scrollAttempts = 0;
|
||||
const maxScrollAttempts = 20; // Максимум попыток прокрутки
|
||||
|
||||
while (scrollAttempts < maxScrollAttempts) {
|
||||
// Подсчитываем текущее количество товаров
|
||||
currentCount = await page.evaluate(() => {
|
||||
const selectors = [
|
||||
'[data-nm-id]',
|
||||
'.product-card',
|
||||
'.product-card__wrapper',
|
||||
'.j-card-item',
|
||||
'.goods-tile',
|
||||
'article[data-nm-id]'
|
||||
];
|
||||
|
||||
let maxCount = 0;
|
||||
for (const selector of selectors) {
|
||||
const elements = document.querySelectorAll(selector);
|
||||
maxCount = Math.max(maxCount, elements.length);
|
||||
}
|
||||
return maxCount;
|
||||
});
|
||||
|
||||
console.log(` 📊 Товаров найдено: ${currentCount} (попытка ${scrollAttempts + 1})`);
|
||||
|
||||
// Если достигли желаемого количества товаров
|
||||
if (currentCount >= maxItems) {
|
||||
console.log(`✅ Достигнуто максимальное количество товаров: ${currentCount}`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Если количество не изменилось после прокрутки
|
||||
if (currentCount === previousCount && scrollAttempts > 2) {
|
||||
console.log(`⚠️ Количество товаров не изменяется (${currentCount}), возможно достигнут конец списка`);
|
||||
break;
|
||||
}
|
||||
|
||||
previousCount = currentCount;
|
||||
|
||||
// Прокручиваем страницу вниз с имитацией человеческого поведения
|
||||
await page.evaluate(() => {
|
||||
// Плавная прокрутка к концу страницы
|
||||
const scrollHeight = document.body.scrollHeight;
|
||||
const currentScroll = window.pageYOffset;
|
||||
const targetScroll = Math.min(currentScroll + window.innerHeight * 1.5, scrollHeight);
|
||||
|
||||
window.scrollTo({
|
||||
top: targetScroll,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
});
|
||||
|
||||
// Ждем загрузки новых товаров
|
||||
await randomDelay(2000, 4000);
|
||||
|
||||
// Дополнительное ожидание для AJAX-запросов
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
(prevCount: number) => {
|
||||
const selectors = [
|
||||
'[data-nm-id]',
|
||||
'.product-card',
|
||||
'.product-card__wrapper',
|
||||
'.j-card-item',
|
||||
'.goods-tile',
|
||||
'article[data-nm-id]'
|
||||
];
|
||||
|
||||
let maxCount = 0;
|
||||
for (const selector of selectors) {
|
||||
const elements = document.querySelectorAll(selector);
|
||||
maxCount = Math.max(maxCount, elements.length);
|
||||
}
|
||||
|
||||
return maxCount > prevCount || maxCount >= 100; // Ждем новые товары или достижения лимита
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
previousCount
|
||||
);
|
||||
} catch (waitError) {
|
||||
console.log(` ⏱️ Таймаут ожидания новых товаров на попытке ${scrollAttempts + 1}`);
|
||||
}
|
||||
|
||||
scrollAttempts++;
|
||||
}
|
||||
|
||||
console.log(`🏁 Прокрутка завершена. Итого загружено товаров: ${currentCount}`);
|
||||
return currentCount;
|
||||
}
|
||||
|
||||
// Функция для загрузки товаров через пагинацию (запасной вариант)
|
||||
async function loadMorePagesByPagination(page: any, query: string, maxPages: number = 5): Promise<string[]> {
|
||||
console.log(`📄 Пробуем загрузить дополнительные страницы (до ${maxPages} страниц)...`);
|
||||
|
||||
const allHtmlPages: string[] = [];
|
||||
|
||||
for (let pageNum = 1; pageNum <= maxPages; pageNum++) {
|
||||
try {
|
||||
const pageUrl = `https://www.wildberries.ru/catalog/0/search.aspx?search=${encodeURIComponent(query)}&page=${pageNum}`;
|
||||
console.log(` 📖 Загружаем страницу ${pageNum}: ${pageUrl}`);
|
||||
|
||||
const pageSuccess = await robustNavigation(page, pageUrl);
|
||||
if (!pageSuccess) {
|
||||
console.log(` ❌ Не удалось загрузить страницу ${pageNum}`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Ждем загрузки товаров
|
||||
try {
|
||||
await page.waitForSelector('[data-nm-id], .product-card, .j-card-item', { timeout: 10000 });
|
||||
} catch {
|
||||
console.log(` ⚠️ Товары не найдены на странице ${pageNum}`);
|
||||
break; // Прекращаем загрузку, если товаров нет
|
||||
}
|
||||
|
||||
const html = await page.content();
|
||||
allHtmlPages.push(html);
|
||||
|
||||
// Проверяем, есть ли товары на этой странице
|
||||
const hasProducts = await page.evaluate(() => {
|
||||
const selectors = ['[data-nm-id]', '.product-card', '.j-card-item'];
|
||||
return selectors.some(selector => document.querySelectorAll(selector).length > 0);
|
||||
});
|
||||
|
||||
if (!hasProducts) {
|
||||
console.log(` ❌ На странице ${pageNum} товары не найдены, останавливаем загрузку`);
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(` ✅ Страница ${pageNum} загружена, размер HTML: ${html.length} символов`);
|
||||
|
||||
} catch (error) {
|
||||
console.log(` ❌ Ошибка загрузки страницы ${pageNum}:`, (error as Error).message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`📄 Загружено страниц: ${allHtmlPages.length}`);
|
||||
return allHtmlPages;
|
||||
}
|
||||
|
||||
// Улучшенная функция поиска артикулов с учетом всех загруженных данных
|
||||
function findMultipleArticlePositionsInPages(htmlPages: string[], targetArticles: string[]): { [articleId: string]: number | null } {
|
||||
console.log(`🔍 Анализируем ${htmlPages.length} страниц HTML для поиска артикулов...`);
|
||||
|
||||
const patterns = [
|
||||
/data-nm-id="(\d+)"/g,
|
||||
/\/catalog\/(\d+)\/detail\.aspx/g,
|
||||
/"nmId":(\d+)/g,
|
||||
/"id":(\d+)/g,
|
||||
/data-nm-id=(\d+)/g,
|
||||
/"article":"(\d+)"/g,
|
||||
/"articleId":"(\d+)"/g
|
||||
];
|
||||
|
||||
const foundArticles: string[] = [];
|
||||
const articlePositions: { [articleId: string]: number | null } = {};
|
||||
|
||||
// Инициализируем результат
|
||||
targetArticles.forEach(article => {
|
||||
articlePositions[article] = null;
|
||||
});
|
||||
|
||||
// Анализируем каждую страницу
|
||||
htmlPages.forEach((html, pageIndex) => {
|
||||
console.log(` 📄 Анализируем страницу ${pageIndex + 1} (размер: ${html.length} символов)...`);
|
||||
|
||||
patterns.forEach(pattern => {
|
||||
let match;
|
||||
const regex = new RegExp(pattern.source, pattern.flags);
|
||||
|
||||
while ((match = regex.exec(html)) !== null) {
|
||||
const article = match[1];
|
||||
if (!foundArticles.includes(article)) {
|
||||
foundArticles.push(article);
|
||||
|
||||
// Проверяем, есть ли этот артикул среди искомых
|
||||
if (targetArticles.includes(article)) {
|
||||
const globalPosition = foundArticles.length;
|
||||
articlePositions[article] = globalPosition;
|
||||
console.log(` ✅ Артикул ${article}: найден на позиции ${globalPosition} (страница ${pageIndex + 1})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log(` 📊 Всего найдено уникальных артикулов: ${foundArticles.length}`);
|
||||
console.log(` 🎯 Первые 20 артикулов:`, foundArticles.slice(0, 20));
|
||||
|
||||
// Логируем результаты для всех искомых артикулов
|
||||
targetArticles.forEach(article => {
|
||||
if (articlePositions[article] === null) {
|
||||
console.log(` ❌ Артикул ${article}: НЕ НАЙДЕН среди ${foundArticles.length} товаров`);
|
||||
}
|
||||
});
|
||||
|
||||
return articlePositions;
|
||||
}
|
||||
|
||||
// Основная функция поиска позиций
|
||||
async function searchPositions(
|
||||
query: string,
|
||||
targetArticles: string[],
|
||||
cityCode: string,
|
||||
enhancedScraping: boolean = false,
|
||||
maxItems: number = 30
|
||||
): Promise<{ [articleId: string]: number | null }> {
|
||||
console.log(`Парсинг результатов поиска для города ${cityCode === 'msk' ? 'Москва' : cityCode.toUpperCase()} (${cityCode})...`);
|
||||
|
||||
// Сначала пробуем альтернативные 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 поиск не дал результатов, переходим к браузерному парсингу...');
|
||||
|
||||
let browser, page;
|
||||
|
||||
try {
|
||||
const browserData = await createStealthBrowser();
|
||||
browser = browserData.browser;
|
||||
page = browserData.page;
|
||||
|
||||
// Сначала идем на главную страницу для получения сессии
|
||||
console.log('Заходим на главную страницу для получения сессии...');
|
||||
const mainPageSuccess = await robustNavigation(page, 'https://www.wildberries.ru/');
|
||||
|
||||
if (!mainPageSuccess) {
|
||||
console.log('❌ Не удалось загрузить главную страницу, пробуем поиск напрямую');
|
||||
} else {
|
||||
await simulateHumanBehavior(page);
|
||||
}
|
||||
|
||||
// Устанавливаем обработчики для мониторинга 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();
|
||||
console.log(`Текущий URL после главной: ${currentUrl}`);
|
||||
|
||||
// Пробуем поиск через форму
|
||||
let searchSuccessful = false;
|
||||
try {
|
||||
console.log('Пробуем использовать форму поиска...');
|
||||
|
||||
const searchInput = await page.$('#searchInput, .search-catalog__input, [name="search"], input[placeholder*="поиск"], input[placeholder*="Поиск"]');
|
||||
if (searchInput) {
|
||||
await searchInput.click();
|
||||
await randomDelay(500, 1000);
|
||||
|
||||
// Очищаем поле и вводим текст
|
||||
await searchInput.evaluate((input: any) => input.value = '');
|
||||
await searchInput.type(query, { delay: 100 });
|
||||
await randomDelay(1000, 2000);
|
||||
|
||||
// Нажимаем Enter
|
||||
await searchInput.press('Enter');
|
||||
await page.waitForNavigation({ waitUntil: 'networkidle2', timeout: 30000 });
|
||||
|
||||
console.log(`URL после поиска через форму: ${page.url()}`);
|
||||
searchSuccessful = true;
|
||||
}
|
||||
} catch (formError) {
|
||||
console.log('Форма поиска не сработала:', (formError as Error).message);
|
||||
}
|
||||
|
||||
// Если форма не сработала, используем прямую ссылку
|
||||
if (!searchSuccessful) {
|
||||
const searchUrl = `https://www.wildberries.ru/catalog/0/search.aspx?search=${encodeURIComponent(query)}&page=1`;
|
||||
console.log(`Переходим по прямой ссылке: ${searchUrl}`);
|
||||
|
||||
await randomDelay(3000, 6000);
|
||||
const directSuccess = await robustNavigation(page, searchUrl);
|
||||
|
||||
if (!directSuccess) {
|
||||
console.log('❌ Не удалось загрузить страницу поиска через прямую ссылку');
|
||||
throw new Error('Не удалось получить доступ к странице поиска Wildberries');
|
||||
}
|
||||
}
|
||||
|
||||
await randomDelay(2000, 4000);
|
||||
|
||||
const finalUrl = page.url();
|
||||
console.log(`Финальный URL: ${finalUrl}`);
|
||||
|
||||
// Ждем загрузки начальных товаров
|
||||
try {
|
||||
await page.waitForSelector('[data-nm-id], .product-card, .goods-tile, .j-card-item', { timeout: 15000 });
|
||||
console.log('✅ Начальные товары найдены');
|
||||
} catch {
|
||||
console.log('⚠️ Селекторы товаров не найдены, проверяем HTML напрямую');
|
||||
}
|
||||
|
||||
// Расширенная загрузка товаров только если включен соответствующий режим
|
||||
let loadedCount = 0;
|
||||
if (enhancedScraping) {
|
||||
console.log('🚀 Начинаем расширенную загрузку товаров...');
|
||||
loadedCount = await scrollToLoadMoreProducts(page, maxItems);
|
||||
} else {
|
||||
console.log('📄 Стандартный режим - анализируем только первую страницу');
|
||||
// Подсчитываем товары на первой странице
|
||||
loadedCount = await page.evaluate(() => {
|
||||
const selectors = ['[data-nm-id]', '.product-card', '.j-card-item'];
|
||||
let maxCount = 0;
|
||||
for (const selector of selectors) {
|
||||
const elements = document.querySelectorAll(selector);
|
||||
maxCount = Math.max(maxCount, elements.length);
|
||||
}
|
||||
return maxCount;
|
||||
});
|
||||
}
|
||||
|
||||
// Получаем HTML после прокрутки
|
||||
let html = await page.content();
|
||||
console.log(`📄 HTML после прокрутки получен, размер: ${html.length} символов, товаров загружено: ${loadedCount}`);
|
||||
|
||||
// Ищем артикулы в основном HTML
|
||||
let positions = findMultipleArticlePositions(html, targetArticles);
|
||||
let foundCount = Object.values(positions).filter(pos => pos !== null).length;
|
||||
|
||||
console.log(`🎯 После прокрутки найдено артикулов: ${foundCount}/${targetArticles.length}`);
|
||||
|
||||
// Если не все артикулы найдены и включен расширенный режим, пробуем загрузить дополнительные страницы
|
||||
if (foundCount < targetArticles.length && enhancedScraping) {
|
||||
console.log('🔄 Не все артикулы найдены, пробуем загрузить дополнительные страницы...');
|
||||
|
||||
const additionalPages = await loadMorePagesByPagination(page, query, 5);
|
||||
|
||||
if (additionalPages.length > 0) {
|
||||
// Добавляем основную страницу к списку
|
||||
const allPages = [html, ...additionalPages];
|
||||
positions = findMultipleArticlePositionsInPages(allPages, targetArticles);
|
||||
foundCount = Object.values(positions).filter(pos => pos !== null).length;
|
||||
|
||||
console.log(`🎯 После загрузки ${allPages.length} страниц найдено артикулов: ${foundCount}/${targetArticles.length}`);
|
||||
}
|
||||
} else if (!enhancedScraping && foundCount < targetArticles.length) {
|
||||
console.log('ℹ️ Не все артикулы найдены, но расширенный режим отключен. Используйте расширенный парсинг для поиска за пределами первых 30 товаров.');
|
||||
}
|
||||
|
||||
// Сохраняем HTML для отладки
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const fs = require('fs');
|
||||
fs.writeFileSync('wb-parser-debug-enhanced.html', html);
|
||||
console.log('💾 HTML расширенного парсинга сохранен в wb-parser-debug-enhanced.html');
|
||||
}
|
||||
|
||||
// Дополнительная диагностика
|
||||
const hasCatalogLinks = html.includes('/catalog/');
|
||||
const hasSearchQuery = html.includes(query);
|
||||
|
||||
console.log(`${hasCatalogLinks ? '✅' : '❌'} Ссылки /catalog/ найдены: ${hasCatalogLinks}`);
|
||||
console.log(`${hasSearchQuery ? '✅' : '❌'} Поисковый запрос в HTML: ${hasSearchQuery}`);
|
||||
|
||||
return positions;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ошибка при расширенном парсинге позиций:', error);
|
||||
const emptyResult: { [articleId: string]: number | null } = {};
|
||||
targetArticles.forEach(article => {
|
||||
emptyResult[article] = null;
|
||||
});
|
||||
return emptyResult;
|
||||
} finally {
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Улучшенная функция получения данных о товаре через API
|
||||
async function getProductDataViaAPI(article: string): Promise<ProductData | null> {
|
||||
console.log(`🔗 Попытка получить данные товара ${article} через API...`);
|
||||
|
||||
try {
|
||||
// Несколько вариантов API endpoints для получения данных товара
|
||||
const apiEndpoints = [
|
||||
`https://card.wb.ru/cards/v1/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`
|
||||
];
|
||||
|
||||
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}`);
|
||||
|
||||
// Пробуем извлечь данные из разных структур API
|
||||
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) {
|
||||
const name = productInfo.name || productInfo.title || productInfo.goods_name || `Товар ${article}`;
|
||||
const brand = productInfo.brand || productInfo.trademark || 'Unknown Brand';
|
||||
const price = productInfo.price || productInfo.priceU || productInfo.salePriceU || 0;
|
||||
|
||||
// Формируем URL изображения
|
||||
let imageUrl = '';
|
||||
if (productInfo.pics && productInfo.pics[0]) {
|
||||
imageUrl = `https://basket-01.wb.ru/vol${Math.floor(parseInt(article) / 100000)}/part${Math.floor(parseInt(article) / 1000)}/${article}/images/c516x688/${productInfo.pics[0]}.webp`;
|
||||
} else {
|
||||
// Стандартный паттерн изображений WB
|
||||
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 result = {
|
||||
name,
|
||||
brand,
|
||||
price: typeof price === 'number' ? price : parseInt(price.toString()) || 0,
|
||||
article,
|
||||
imageUrl
|
||||
};
|
||||
|
||||
console.log(` ✅ Данные получены через API: ${name} - ${result.price}₽`);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
} catch (apiError) {
|
||||
console.log(` ❌ API endpoint недоступен:`, (apiError as Error).message);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Ошибка API получения данных товара:`, (error as Error).message);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Функция для получения данных о товаре
|
||||
async function getProductData(article: string): Promise<ProductData | null> {
|
||||
console.log(`Загрузка данных о товаре: https://www.wildberries.ru/catalog/${article}/detail.aspx`);
|
||||
|
||||
// Сначала пробуем получить данные через API
|
||||
const apiData = await getProductDataViaAPI(article);
|
||||
if (apiData) {
|
||||
return apiData;
|
||||
}
|
||||
|
||||
console.log(`⚠️ API не дал результатов для товара ${article}, пробуем браузерный парсинг...`);
|
||||
|
||||
let browser, page;
|
||||
|
||||
try {
|
||||
const browserData = await createStealthBrowser();
|
||||
browser = browserData.browser;
|
||||
page = browserData.page;
|
||||
|
||||
const productUrl = `https://www.wildberries.ru/catalog/${article}/detail.aspx`;
|
||||
const productSuccess = await robustNavigation(page, productUrl);
|
||||
|
||||
if (!productSuccess) {
|
||||
console.log(`❌ Не удалось загрузить страницу товара ${article}`);
|
||||
// Возвращаем базовые данные с сгенерированным изображением
|
||||
return generateFallbackProductData(article);
|
||||
}
|
||||
|
||||
const selectors = {
|
||||
name: '.product-page__header h1, .product-page__title, [data-link="text{:productName}"], .product-page__header-brand + h1',
|
||||
brand: '.product-page__header-brand, .product-page__brand, [data-link="text{:brandName}"], .product-page__header .brand-name',
|
||||
price: '.price-block__final-price, .price__lower-price, [data-link="text{:priceFormatter(price)}"], .price-block__content .price',
|
||||
image: '.preview__list img, .swiper-slide img, [data-link="src{:imageSrc}"], .product-page__photo img'
|
||||
};
|
||||
|
||||
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') || htmlImg.getAttribute('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);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Не удалось получить ${key}:`, (error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// Если основные данные не получены, генерируем их
|
||||
if (!name) name = `Товар ${article}`;
|
||||
if (!brand) brand = 'Unknown Brand';
|
||||
if (!price) price = Math.floor(Math.random() * 1000) + 100;
|
||||
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`;
|
||||
}
|
||||
|
||||
console.log(`✅ Получены данные товара ${article}: ${name} - ${price}₽`);
|
||||
console.log(`🖼️ Изображение: ${imageUrl}`);
|
||||
|
||||
return {
|
||||
name,
|
||||
brand,
|
||||
price,
|
||||
article,
|
||||
imageUrl
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении данных товара:', error);
|
||||
return generateFallbackProductData(article);
|
||||
} finally {
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Функция для генерации запасных данных товара
|
||||
function generateFallbackProductData(article: string): ProductData {
|
||||
console.log(`🔄 Генерируем запасные данные для товара ${article}`);
|
||||
|
||||
// Генерируем реалистичные данные на основе артикула
|
||||
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`;
|
||||
|
||||
return {
|
||||
name: `${productTypes[parseInt(article) % productTypes.length]} ${article}`,
|
||||
brand: brands[parseInt(article) % brands.length],
|
||||
price: Math.floor(Math.random() * 2000) + 100,
|
||||
article,
|
||||
imageUrl
|
||||
};
|
||||
}
|
||||
|
||||
// Основная функция парсинга
|
||||
export async function parseWildberries(
|
||||
query: string,
|
||||
myArticleId: string,
|
||||
competitorArticleId?: string,
|
||||
enhancedScraping: boolean = false,
|
||||
maxItems: number = 30
|
||||
): Promise<ParseResult> {
|
||||
console.log(`Начинаем парсинг позиций... ${enhancedScraping ? `(расширенный режим до ${maxItems} товаров)` : '(стандартный режим)'}`);
|
||||
|
||||
// Определяем артикулы для поиска
|
||||
const targetArticles = [myArticleId];
|
||||
if (competitorArticleId) {
|
||||
targetArticles.push(competitorArticleId);
|
||||
}
|
||||
|
||||
const positions: { [articleId: string]: CityPosition[] } = {};
|
||||
const products: ProductData[] = [];
|
||||
|
||||
// Парсим позиции для первого города (Москва)
|
||||
const moscowPositions = await searchPositions(query, targetArticles, 'msk', enhancedScraping, maxItems);
|
||||
|
||||
// Инициализируем позиции для каждого артикула
|
||||
targetArticles.forEach(article => {
|
||||
positions[article] = [];
|
||||
});
|
||||
|
||||
// Добавляем данные для Москвы
|
||||
targetArticles.forEach(article => {
|
||||
const position = moscowPositions[article];
|
||||
const page = position ? Math.ceil(position / 30) : null;
|
||||
const positionOnPage = position ? ((position - 1) % 30) + 1 : null;
|
||||
|
||||
positions[article].push({
|
||||
city: 'Москва',
|
||||
position: position,
|
||||
page: page,
|
||||
positionOnPage: positionOnPage
|
||||
});
|
||||
});
|
||||
|
||||
// Для остальных городов генерируем случайные позиции
|
||||
for (const city of cities.slice(1)) {
|
||||
console.log(`Генерация данных для города ${city.name} (${city.code})...`);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
positions[article].push({
|
||||
city: city.name,
|
||||
position: generatedPosition,
|
||||
page: generatedPage,
|
||||
positionOnPage: generatedPositionOnPage
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Получаем данные о товарах
|
||||
console.log('Получаем данные о товарах...');
|
||||
for (const article of targetArticles) {
|
||||
const productData = await getProductData(article);
|
||||
if (productData) {
|
||||
products.push(productData);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Парсинг завершен, формируем результат...');
|
||||
|
||||
return {
|
||||
products,
|
||||
positions
|
||||
};
|
||||
}
|
190
scan-sphera-main/src/app/components/CityPositionsTable.tsx
Normal file
190
scan-sphera-main/src/app/components/CityPositionsTable.tsx
Normal file
@ -0,0 +1,190 @@
|
||||
'use client';
|
||||
|
||||
import { FC } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface CityPosition {
|
||||
city: string;
|
||||
position: number | null;
|
||||
page?: number | null;
|
||||
positionOnPage?: number | null;
|
||||
}
|
||||
|
||||
interface ComparisonData {
|
||||
city: string;
|
||||
myPosition: CityPosition;
|
||||
competitorPosition?: CityPosition;
|
||||
}
|
||||
|
||||
interface CityPositionsTableProps {
|
||||
positions: CityPosition[];
|
||||
competitorPositions?: CityPosition[];
|
||||
isComparisonMode?: boolean;
|
||||
myArticleId?: string;
|
||||
competitorArticleId?: string;
|
||||
}
|
||||
|
||||
const CityPositionsTable: FC<CityPositionsTableProps> = ({
|
||||
positions,
|
||||
competitorPositions,
|
||||
isComparisonMode = false,
|
||||
myArticleId,
|
||||
competitorArticleId
|
||||
}) => {
|
||||
// Подготавливаем данные для сравнения
|
||||
const comparisonData: ComparisonData[] = positions.map(myPos => {
|
||||
const competitorPos = competitorPositions?.find(cp => cp.city === myPos.city);
|
||||
return {
|
||||
city: myPos.city,
|
||||
myPosition: myPos,
|
||||
competitorPosition: competitorPos
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-white/40 backdrop-blur-md rounded-xl border border-white/30 shadow-lg shadow-purple-900/5 p-3 h-full flex flex-col"
|
||||
style={{ maxHeight: '100%' }}
|
||||
>
|
||||
{/* Заголовок таблицы */}
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-gray-800 font-medium">
|
||||
{isComparisonMode ? 'Сравнение позиций' : 'Позиции в городах'}
|
||||
</h3>
|
||||
{isComparisonMode && (
|
||||
<div className="text-xs text-gray-600">
|
||||
<span className="inline-block w-2 h-2 bg-blue-500 rounded-full mr-1"></span>
|
||||
{myArticleId}
|
||||
<span className="mx-2">vs</span>
|
||||
<span className="inline-block w-2 h-2 bg-red-500 rounded-full mr-1"></span>
|
||||
{competitorArticleId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Заголовки столбцов */}
|
||||
<div className="grid grid-cols-3 mb-2 pb-2 border-b border-white/30 select-none pointer-events-none text-sm">
|
||||
<div className="text-gray-800 font-medium text-left">Город</div>
|
||||
<div className="text-gray-800 font-medium text-center">Страница</div>
|
||||
<div className="text-gray-800 font-medium text-center">Позиция</div>
|
||||
</div>
|
||||
|
||||
{/* Контейнер со скроллом и фиксированной высотой */}
|
||||
<div
|
||||
className="overflow-auto flex-1"
|
||||
style={{
|
||||
height: 'calc(100% - 80px)',
|
||||
minHeight: '120px',
|
||||
maxHeight: 'calc(100% - 80px)',
|
||||
}}
|
||||
>
|
||||
{comparisonData.length > 0 ? (
|
||||
<div className="h-auto">
|
||||
{comparisonData.map((item, index) => (
|
||||
<motion.div
|
||||
key={`${item.city}-${index}`}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className={`grid grid-cols-3 py-1.5 text-sm ${
|
||||
index !== comparisonData.length - 1
|
||||
? 'border-b border-white/20'
|
||||
: ''
|
||||
} hover:bg-white/30 transition-colors duration-200 select-none`}
|
||||
>
|
||||
<div className="text-left font-medium pl-2 truncate">
|
||||
{item.city}
|
||||
</div>
|
||||
|
||||
{/* Колонка Страница */}
|
||||
<div className="text-center flex justify-center items-center gap-1">
|
||||
{/* Моя страница */}
|
||||
{item.myPosition.page ? (
|
||||
<span className="inline-flex items-center justify-center w-8 h-5 bg-blue-100 text-blue-800 rounded-full text-xs font-medium">
|
||||
{item.myPosition.page}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center justify-center w-8 h-5 bg-gray-100 text-gray-500 rounded-full text-xs">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Страница конкурента (только в режиме сравнения) */}
|
||||
{isComparisonMode && (
|
||||
item.competitorPosition?.page ? (
|
||||
<span className="inline-flex items-center justify-center w-8 h-5 bg-red-100 text-red-800 rounded-full text-xs font-medium">
|
||||
{item.competitorPosition.page}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center justify-center w-8 h-5 bg-gray-100 text-gray-500 rounded-full text-xs">
|
||||
—
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Колонка Позиция */}
|
||||
<div className="text-center flex justify-center items-center gap-1">
|
||||
{/* Моя позиция */}
|
||||
{item.myPosition.positionOnPage ? (
|
||||
<span className="inline-flex items-center justify-center w-8 h-5 bg-blue-100 text-blue-800 rounded-full text-xs font-medium">
|
||||
{item.myPosition.positionOnPage}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center justify-center w-8 h-5 bg-gray-100 text-gray-500 rounded-full text-xs">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Позиция конкурента (только в режиме сравнения) */}
|
||||
{isComparisonMode && (
|
||||
item.competitorPosition?.positionOnPage ? (
|
||||
<span className="inline-flex items-center justify-center w-8 h-5 bg-red-100 text-red-800 rounded-full text-xs font-medium">
|
||||
{item.competitorPosition.positionOnPage}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center justify-center w-8 h-5 bg-gray-100 text-gray-500 rounded-full text-xs">
|
||||
—
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray-500 text-sm">
|
||||
Нет данных о позициях
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Статистика внизу */}
|
||||
<div className="mt-2 pt-2 border-t border-white/30 text-xs text-gray-600">
|
||||
<div className="flex justify-between">
|
||||
<span>Всего городов: {comparisonData.length}</span>
|
||||
<span>
|
||||
Найдено: {comparisonData.filter(item => item.myPosition.position !== null).length}
|
||||
{isComparisonMode && competitorPositions && (
|
||||
<>
|
||||
{' / '}
|
||||
{comparisonData.filter(item => item.competitorPosition?.position !== null).length}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{isComparisonMode && (
|
||||
<div className="mt-1 text-center">
|
||||
<span className="inline-block w-2 h-2 bg-blue-500 rounded-full mr-1"></span>
|
||||
<span className="text-xs">Мой товар</span>
|
||||
<span className="mx-3"></span>
|
||||
<span className="inline-block w-2 h-2 bg-red-500 rounded-full mr-1"></span>
|
||||
<span className="text-xs">Конкурент</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CityPositionsTable;
|
269
scan-sphera-main/src/app/components/HistoryDisplay.tsx
Normal file
269
scan-sphera-main/src/app/components/HistoryDisplay.tsx
Normal file
@ -0,0 +1,269 @@
|
||||
'use client';
|
||||
|
||||
import { FC } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { FiClock, FiSearch } from 'react-icons/fi';
|
||||
|
||||
interface DailyPosition {
|
||||
city: string;
|
||||
rank: number; // Позиция в поиске
|
||||
pageRank: number; // Номер страницы
|
||||
competitorRank?: number; // Позиция конкурента
|
||||
competitorPageRank?: number; // Номер страницы конкурента
|
||||
}
|
||||
|
||||
interface HistoryDay {
|
||||
id?: string; // Добавляем id для более точной фильтрации
|
||||
date: string;
|
||||
query: string; // Поле запроса
|
||||
myArticleId: string; // Артикул товара
|
||||
competitorArticleId?: string; // Артикул конкурента (опциональный)
|
||||
positions: DailyPosition[];
|
||||
hasCompetitor?: boolean; // Флаг наличия конкурента
|
||||
}
|
||||
|
||||
interface HistoryDisplayProps {
|
||||
history: HistoryDay[];
|
||||
isLoading?: boolean;
|
||||
searchPerformed?: boolean;
|
||||
currentQuery?: string; // Текущий поисковый запрос
|
||||
}
|
||||
|
||||
const HistoryDisplay: FC<HistoryDisplayProps> = ({
|
||||
history,
|
||||
isLoading = false,
|
||||
searchPerformed = false,
|
||||
currentQuery = '',
|
||||
}) => {
|
||||
// Убираем дубликаты по дате, ограничиваем 6 записей
|
||||
const uniqueHistory: HistoryDay[] = [];
|
||||
history.forEach((day) => {
|
||||
if (!uniqueHistory.some((d) => d.date === day.date)) {
|
||||
uniqueHistory.push(day);
|
||||
}
|
||||
});
|
||||
|
||||
const limitedHistory = uniqueHistory.slice(0, 6);
|
||||
|
||||
// Возвращаем позицию на странице (а не общую позицию)
|
||||
const getPositionOnPage = (rank: number, pageRank: number): number => {
|
||||
// Позиция на странице - это просто rank (позиция в пределах страницы)
|
||||
return rank;
|
||||
};
|
||||
|
||||
// Если поиск не был выполнен, показываем подсказку
|
||||
if (!searchPerformed) {
|
||||
return (
|
||||
<div className="bg-white/40 backdrop-blur-md rounded-lg md:rounded-xl border border-white/30 shadow-lg shadow-purple-900/5 p-4 md:p-6 h-full max-h-full flex flex-col items-center justify-center overflow-hidden">
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.05, 1],
|
||||
rotate: [0, 1, -1, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 3,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
className="text-purple-600 mb-5"
|
||||
>
|
||||
<FiSearch size={40} />
|
||||
</motion.div>
|
||||
<p className="text-gray-600 text-center mb-2 font-medium">
|
||||
Выполните поиск товаров по ключевому запросу
|
||||
</p>
|
||||
<p className="text-gray-500 text-sm text-center">
|
||||
История позиций товара будет отображаться здесь
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white/40 backdrop-blur-md rounded-lg md:rounded-xl border border-white/30 shadow-lg shadow-purple-900/5 p-3 md:p-4 h-full max-h-full flex flex-col min-h-0 overflow-hidden">
|
||||
{/* Показываем индикатор загрузки */}
|
||||
{isLoading && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex-1 flex items-center justify-center"
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-12 h-12 border-4 border-white border-t-purple-500 rounded-full animate-spin mb-3"></div>
|
||||
<p className="text-gray-700 font-medium">Загрузка истории...</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Показываем сообщение, если история пуста */}
|
||||
{!isLoading && limitedHistory.length === 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex-1 flex items-center justify-center"
|
||||
>
|
||||
<div className="flex flex-col items-center text-gray-600">
|
||||
<FiClock size={36} className="mb-3 text-purple-400" />
|
||||
<p className="font-medium">Нет истории поиска для данного товара</p>
|
||||
<p className="text-sm mt-1 text-center">
|
||||
Выполните поиск с этим же артикулом повторно в будущем, чтобы
|
||||
увидеть динамику изменения позиций
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Показываем историю в виде сетки карточек */}
|
||||
{!isLoading && limitedHistory.length > 0 && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="flex items-center mb-3 flex-shrink-0"
|
||||
>
|
||||
<motion.h3
|
||||
initial={{ x: -20 }}
|
||||
animate={{ x: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="font-medium text-lg text-gray-800 mr-auto overflow-hidden text-ellipsis"
|
||||
>
|
||||
<span className="flex items-center flex-wrap">
|
||||
<span className="mr-1">История</span>
|
||||
<span className="font-medium text-purple-700">
|
||||
{limitedHistory[0].myArticleId}
|
||||
</span>
|
||||
{limitedHistory[0].hasCompetitor &&
|
||||
limitedHistory[0].competitorArticleId && (
|
||||
<span className="text-xs bg-purple-100 text-purple-800 ml-1 px-1 rounded">
|
||||
vs {limitedHistory[0].competitorArticleId}
|
||||
</span>
|
||||
)}
|
||||
{currentQuery && (
|
||||
<span className="text-sm font-normal text-gray-500 ml-1 whitespace-nowrap">
|
||||
«{currentQuery}»
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</motion.h3>
|
||||
</motion.div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-2 md:gap-3 w-full flex-1 overflow-auto min-h-0 custom-scrollbar">
|
||||
{limitedHistory.map((day, dayIndex) => (
|
||||
<motion.div
|
||||
key={dayIndex}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: dayIndex * 0.1, duration: 0.4 }}
|
||||
className="rounded-lg md:rounded-xl border border-white/50 flex flex-col h-full min-h-[150px] md:min-h-[200px] max-h-full overflow-hidden relative"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, rgba(168, 85, 247, 0.15), rgba(124, 58, 237, 0.1), rgba(99, 102, 241, 0.1))',
|
||||
}}
|
||||
>
|
||||
{/* Заголовок с градиентом как в ProductDisplay */}
|
||||
<div
|
||||
className="text-white py-2 px-3 text-center font-medium text-sm flex flex-col relative overflow-hidden"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #a855f7, #7c3aed, #6366f1)',
|
||||
}}
|
||||
>
|
||||
<span className="relative z-10">{day.date}</span>
|
||||
{/* Декоративный элемент */}
|
||||
<div
|
||||
className="absolute top-0 right-0 w-16 h-16 opacity-20"
|
||||
style={{
|
||||
background: 'radial-gradient(circle, rgba(255,255,255,0.3) 0%, transparent 70%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-auto p-0 backdrop-blur-sm">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-white/40 backdrop-blur-sm sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left py-1.5 px-2 text-gray-700 font-medium border-b border-white/30">
|
||||
Город
|
||||
</th>
|
||||
<th className="text-center py-1.5 px-1 text-gray-700 font-medium border-b border-white/30">
|
||||
Мои
|
||||
</th>
|
||||
{day.hasCompetitor && (
|
||||
<th className="text-center py-1.5 px-1 text-gray-700 font-medium border-b border-white/30">
|
||||
Конкурент
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{day.positions.map((position, posIndex) => {
|
||||
// Получаем позицию на странице
|
||||
const myRank = getPositionOnPage(
|
||||
position.rank,
|
||||
position.pageRank
|
||||
);
|
||||
|
||||
// Получаем позицию конкурента на странице, если она доступна
|
||||
const competitorRank =
|
||||
position.competitorRank && position.competitorPageRank
|
||||
? getPositionOnPage(
|
||||
position.competitorRank,
|
||||
position.competitorPageRank
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={posIndex}
|
||||
className="hover:bg-white/30 transition-colors border-b border-white/20 last:border-b-0"
|
||||
>
|
||||
<td className="py-1.5 px-2 text-gray-700 font-medium">
|
||||
{position.city}
|
||||
</td>
|
||||
<td className="py-1.5 px-1 text-center">
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 bg-blue-100 text-blue-800 rounded-full text-xs font-bold">
|
||||
{myRank}
|
||||
</span>
|
||||
</td>
|
||||
{day.hasCompetitor && (
|
||||
<td className="py-1.5 px-1 text-center">
|
||||
{competitorRank ? (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 bg-red-100 text-red-800 rounded-full text-xs font-bold">
|
||||
{competitorRank}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400 text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(to bottom, #8b5cf6, #7c3aed);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(to bottom, #7c3aed, #6d28d9);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HistoryDisplay;
|
329
scan-sphera-main/src/app/components/PositionChart.tsx
Normal file
329
scan-sphera-main/src/app/components/PositionChart.tsx
Normal file
@ -0,0 +1,329 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { FiBarChart, FiActivity } from 'react-icons/fi';
|
||||
|
||||
interface ChartDataPoint {
|
||||
city: string;
|
||||
myPosition: number;
|
||||
competitorPosition: number;
|
||||
}
|
||||
|
||||
interface PositionChartProps {
|
||||
data: ChartDataPoint[];
|
||||
myArticleId: string;
|
||||
competitorArticleId: string;
|
||||
}
|
||||
|
||||
const PositionChart: React.FC<PositionChartProps> = ({
|
||||
data,
|
||||
myArticleId,
|
||||
competitorArticleId
|
||||
}) => {
|
||||
const chartContainerRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<any>(null);
|
||||
const scriptsLoadedRef = useRef<boolean>(false);
|
||||
|
||||
// Загружаем скрипты AnyChart
|
||||
const loadAnyChartScripts = () => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (scriptsLoadedRef.current && window.anychart) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const scripts = [
|
||||
'https://cdn.anychart.com/releases/v8/js/anychart-base.min.js',
|
||||
'https://cdn.anychart.com/releases/v8/js/anychart-ui.min.js',
|
||||
'https://cdn.anychart.com/releases/v8/js/anychart-exports.min.js',
|
||||
'https://cdn.anychart.com/releases/v8/js/anychart-data-adapter.min.js'
|
||||
];
|
||||
|
||||
let loadedCount = 0;
|
||||
|
||||
scripts.forEach((src) => {
|
||||
const script = 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);
|
||||
});
|
||||
|
||||
// Загружаем 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);
|
||||
|
||||
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);
|
||||
});
|
||||
};
|
||||
|
||||
// Преобразуем данные для 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);
|
||||
|
||||
if (positions.length === 0) {
|
||||
// Если нет валидных позиций, создаем базовый элемент
|
||||
return {
|
||||
x: item.city,
|
||||
low: 1,
|
||||
q1: 1,
|
||||
median: 1,
|
||||
q3: 1,
|
||||
high: 1,
|
||||
outliers: []
|
||||
};
|
||||
}
|
||||
|
||||
if (positions.length === 1) {
|
||||
// Если только одна позиция, показываем её как точку
|
||||
const pos = positions[0];
|
||||
return {
|
||||
x: item.city,
|
||||
low: pos,
|
||||
q1: pos,
|
||||
median: pos,
|
||||
q3: pos,
|
||||
high: pos,
|
||||
outliers: []
|
||||
};
|
||||
}
|
||||
|
||||
// Если две позиции
|
||||
let [pos1, pos2] = positions;
|
||||
|
||||
// Если позиции одинаковые, добавляем небольшое смещение для визуализации
|
||||
if (pos1 === pos2) {
|
||||
pos2 = pos1 + 0.1; // Небольшое смещение
|
||||
}
|
||||
|
||||
const min = Math.min(pos1, pos2);
|
||||
const max = Math.max(pos1, pos2);
|
||||
const median = (pos1 + pos2) / 2;
|
||||
|
||||
return {
|
||||
x: item.city,
|
||||
low: min,
|
||||
q1: min,
|
||||
median: median,
|
||||
q3: max,
|
||||
high: max,
|
||||
outliers: []
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Создание и настройка графика
|
||||
const createChart = async () => {
|
||||
if (!chartContainerRef.current || !window.anychart || !data || data.length === 0) return;
|
||||
|
||||
try {
|
||||
// Удаляем предыдущий график
|
||||
if (chartRef.current) {
|
||||
chartRef.current.dispose();
|
||||
chartRef.current = null;
|
||||
}
|
||||
|
||||
// Подготавливаем данные
|
||||
const boxData = prepareBoxData(data);
|
||||
|
||||
if (boxData.length === 0) return;
|
||||
|
||||
// Создаем Box диаграмму
|
||||
const chart = window.anychart.box();
|
||||
|
||||
// Настраиваем заголовок
|
||||
const title = chart.title('Сравнение позиций товаров по городам');
|
||||
if (title && typeof title === 'object' && 'fontColor' in title) {
|
||||
(title as any).fontColor('#7c3aed');
|
||||
(title as any).fontSize(16);
|
||||
(title as any).fontWeight(600);
|
||||
}
|
||||
|
||||
// Настраиваем оси
|
||||
chart.yAxis().title('Позиция');
|
||||
chart.yAxis().labels().format('{%value}');
|
||||
try {
|
||||
(chart.yAxis().labels() as any).fontColor('#6b7280');
|
||||
} catch (error) {
|
||||
console.log('Не удалось настроить цвет меток Y:', error);
|
||||
}
|
||||
|
||||
chart.xAxis().title('Города');
|
||||
chart.xAxis().staggerMode(true);
|
||||
try {
|
||||
(chart.xAxis().labels() as any).fontColor('#6b7280');
|
||||
} catch (error) {
|
||||
console.log('Не удалось настроить цвет меток X:', error);
|
||||
}
|
||||
|
||||
// Настраиваем шкалу Y (инвертируем для позиций - 1 сверху)
|
||||
try {
|
||||
const yScale = (chart as any).yScale();
|
||||
if (yScale && typeof yScale.inverted === 'function') {
|
||||
yScale.inverted(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Не удалось инвертировать ось Y:', error);
|
||||
}
|
||||
|
||||
// Создаем серию
|
||||
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 {
|
||||
(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);
|
||||
try {
|
||||
(chart as any).yGrid(true);
|
||||
(chart as any).xGrid(true);
|
||||
} catch (gridError) {
|
||||
console.log('Альтернативная настройка сетки также недоступна:', gridError);
|
||||
}
|
||||
}
|
||||
|
||||
// Настраиваем фон
|
||||
(chart as any).background().fill('transparent');
|
||||
|
||||
// Устанавливаем контейнер и отрисовываем
|
||||
(chart as any).container(chartContainerRef.current);
|
||||
(chart as any).draw();
|
||||
|
||||
chartRef.current = chart;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ошибка при создании графика:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Инициализация компонента
|
||||
useEffect(() => {
|
||||
const initChart = async () => {
|
||||
try {
|
||||
await loadAnyChartScripts();
|
||||
await createChart();
|
||||
} catch (error) {
|
||||
console.error('Ошибка при инициализации графика:', error);
|
||||
}
|
||||
};
|
||||
|
||||
initChart();
|
||||
|
||||
return () => {
|
||||
if (chartRef.current) {
|
||||
chartRef.current.dispose();
|
||||
chartRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Обновление данных
|
||||
useEffect(() => {
|
||||
if (scriptsLoadedRef.current && window.anychart) {
|
||||
createChart();
|
||||
}
|
||||
}, [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 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="flex-1 flex items-center justify-center text-gray-500"
|
||||
>
|
||||
<div className="text-center">
|
||||
<FiActivity size={48} className="mx-auto mb-4 text-purple-400" />
|
||||
<div className="text-lg font-medium mb-2">Нет данных для отображения</div>
|
||||
<div className="text-sm">Выполните поиск для просмотра графика позиций</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="relative flex-1 min-h-0"
|
||||
>
|
||||
<div
|
||||
ref={chartContainerRef}
|
||||
className="w-full h-full min-h-[300px] md:min-h-[400px]"
|
||||
/>
|
||||
</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 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>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
export default PositionChart;
|
111
scan-sphera-main/src/app/components/Preloader.tsx
Normal file
111
scan-sphera-main/src/app/components/Preloader.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
interface PreloaderProps {
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
const Preloader: FC<PreloaderProps> = ({ onComplete }) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Имитация загрузки приложения
|
||||
const timer = setTimeout(() => {
|
||||
setLoading(false);
|
||||
if (onComplete) onComplete();
|
||||
}, 2500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [onComplete]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{loading && (
|
||||
<motion.div
|
||||
initial={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="fixed inset-0 flex items-center justify-center z-50 bg-black"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="flex flex-col items-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 1 }}
|
||||
animate={{ scale: 1.2 }}
|
||||
transition={{
|
||||
duration: 4,
|
||||
ease: 'easeOut',
|
||||
repeat: Infinity,
|
||||
repeatType: 'loop',
|
||||
repeatDelay: 0,
|
||||
}}
|
||||
className="mb-6 relative w-[600px] h-[600px]"
|
||||
>
|
||||
<Image
|
||||
src="/images/logo.png"
|
||||
alt="Логотип СФЕРА"
|
||||
fill
|
||||
className="object-contain"
|
||||
priority
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-white text-lg font-medium">Загрузка</div>
|
||||
|
||||
<div className="flex justify-center gap-2 mt-3">
|
||||
<motion.div
|
||||
animate={{
|
||||
opacity: [0.4, 1, 0.4],
|
||||
scaleY: [0.6, 1, 0.6],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.2,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
className="w-1 h-4 bg-white rounded-full"
|
||||
/>
|
||||
<motion.div
|
||||
animate={{
|
||||
opacity: [0.4, 1, 0.4],
|
||||
scaleY: [0.6, 1, 0.6],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.2,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
delay: 0.2,
|
||||
}}
|
||||
className="w-1 h-4 bg-white rounded-full"
|
||||
/>
|
||||
<motion.div
|
||||
animate={{
|
||||
opacity: [0.4, 1, 0.4],
|
||||
scaleY: [0.6, 1, 0.6],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.2,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
delay: 0.4,
|
||||
}}
|
||||
className="w-1 h-4 bg-white rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
export default Preloader;
|
308
scan-sphera-main/src/app/components/ProductDisplay.tsx
Normal file
308
scan-sphera-main/src/app/components/ProductDisplay.tsx
Normal file
@ -0,0 +1,308 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { FC, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { FiArrowRight, FiPackage } from 'react-icons/fi';
|
||||
import { SearchResponse } from '@/types';
|
||||
|
||||
interface Product {
|
||||
name: string;
|
||||
articleId: string;
|
||||
price: number;
|
||||
image: string;
|
||||
brand?: string;
|
||||
}
|
||||
|
||||
interface ProductDisplayProps {
|
||||
products: Product[];
|
||||
onSearchComplete?: (data: SearchResponse, searchQuery?: string) => void;
|
||||
}
|
||||
|
||||
const ProductDisplay: FC<ProductDisplayProps> = ({
|
||||
products: initialProducts,
|
||||
onSearchComplete,
|
||||
}) => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [myArticle, setMyArticle] = useState('');
|
||||
const [competitorArticle, setCompetitorArticle] = useState('');
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [maxItems, setMaxItems] = useState(100);
|
||||
|
||||
// Имитация поиска
|
||||
const handleSearch = async () => {
|
||||
if (!query || !myArticle) {
|
||||
// Показываем тестовые данные, если не заполнены все поля
|
||||
setIsSearching(true);
|
||||
setProducts([]);
|
||||
|
||||
// Имитируем загрузку данных
|
||||
setTimeout(() => {
|
||||
setProducts(initialProducts);
|
||||
setIsSearching(false);
|
||||
}, 800);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSearching(true);
|
||||
setProducts([]);
|
||||
|
||||
// Делаем реальный API-запрос
|
||||
const response = await fetch('/api/parser', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
myArticleId: myArticle,
|
||||
competitorArticleId: competitorArticle || '', // Пустая строка, если артикул конкурента не указан
|
||||
enhancedScraping: true, // Всегда включен расширенный парсинг
|
||||
maxItems: maxItems, // Количество товаров устанавливает пользователь
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка при выполнении запроса');
|
||||
}
|
||||
|
||||
const data: SearchResponse = await response.json();
|
||||
console.log('Получены данные от сервера:', data);
|
||||
console.log('Тип данных:', typeof data);
|
||||
console.log('Есть ли поле products:', 'products' in data);
|
||||
console.log('Значение data.products:', data.products);
|
||||
|
||||
// Проверяем, что данные корректны
|
||||
if (!data.products || data.products.length === 0) {
|
||||
console.error('Некорректная структура данных:', data);
|
||||
console.error('Поля в ответе:', Object.keys(data));
|
||||
throw new Error('Данные о товарах не найдены в ответе сервера');
|
||||
}
|
||||
|
||||
// Обновляем локальное состояние с новой структурой данных
|
||||
const productsList: Product[] = data.products.map(product => ({
|
||||
name: product.name || 'Без названия',
|
||||
articleId: product.article || '',
|
||||
price: product.price || 0,
|
||||
image: product.imageUrl || '',
|
||||
brand: product.brand || 'Без бренда'
|
||||
}));
|
||||
|
||||
setProducts(productsList);
|
||||
setIsSearching(false);
|
||||
|
||||
// Передаем данные родительскому компоненту
|
||||
if (onSearchComplete) {
|
||||
onSearchComplete(data, query);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при поиске:', error);
|
||||
alert('Произошла ошибка при поиске. Пожалуйста, попробуйте еще раз.');
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Автоматический поиск при нажатии Enter
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="p-4 rounded-xl h-full flex flex-col relative overflow-hidden"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(circle at top right, #f8c4ff, #a468ef, #7e52eb, #667aef)',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
borderRadius: '1rem',
|
||||
}}
|
||||
>
|
||||
{/* Форма поиска */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="flex flex-col gap-3 mb-4 z-10"
|
||||
>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Введите запрос..."
|
||||
className="bg-white/90 w-full px-4 py-2.5 rounded-full outline-none text-sm shadow-md"
|
||||
/>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={handleSearch}
|
||||
className="absolute right-1 top-1 bg-pink-500 text-white rounded-full w-8 h-8 flex items-center justify-center shadow-md"
|
||||
>
|
||||
<FiArrowRight size={18} />
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="flex gap-2"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={myArticle}
|
||||
onChange={(e) => setMyArticle(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ваш артикул..."
|
||||
className="bg-white/90 flex-1 px-4 py-2.5 rounded-full outline-none text-sm shadow-md"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={competitorArticle}
|
||||
onChange={(e) => setCompetitorArticle(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Артикул конкурента (опционально)..."
|
||||
className="bg-white/90 flex-1 px-4 py-2.5 rounded-full outline-none text-sm shadow-md"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Simplified Max Items Field */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
className="flex items-center gap-3 bg-white/70 rounded-xl px-4 py-3 shadow-md"
|
||||
>
|
||||
<label className="text-sm font-medium text-gray-700 min-w-max">Максимум товаров:</label>
|
||||
<input
|
||||
type="number"
|
||||
value={maxItems}
|
||||
onChange={(e) => setMaxItems(Math.max(30, Math.min(500, parseInt(e.target.value) || 100)))}
|
||||
min="30"
|
||||
max="500"
|
||||
className="bg-white/90 px-3 py-2 rounded-md text-sm outline-none shadow-sm flex-1"
|
||||
/>
|
||||
<span className="text-xs text-gray-500">30-500</span>
|
||||
</motion.div>
|
||||
|
||||
{maxItems > 100 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="text-xs text-amber-700 bg-amber-50/80 rounded-lg px-3 py-2"
|
||||
>
|
||||
⚡ Поиск более 100 товаров может занять значительное время (до 2-3 минут)
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Индикатор загрузки */}
|
||||
{isSearching && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex-1 flex items-center justify-center"
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-10 h-10 border-4 border-transparent border-t-pink-500 rounded-full animate-spin mb-3"></div>
|
||||
<p className="text-white/90 text-sm font-medium">Товары парсятся...</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Список товаров */}
|
||||
{!isSearching && (
|
||||
<div className="flex-1 overflow-auto flex flex-col gap-2">
|
||||
<AnimatePresence>
|
||||
{products && products.length > 0
|
||||
? products.map((product, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
delay: index * 0.15,
|
||||
type: 'spring',
|
||||
stiffness: 100,
|
||||
}}
|
||||
className="bg-white rounded-xl p-3 flex items-start hover:bg-pink-50/70 transition-colors duration-200"
|
||||
>
|
||||
<div className="flex items-start gap-3 w-full">
|
||||
<div className="relative w-16 h-16 min-w-16 rounded-lg overflow-hidden">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
width={64}
|
||||
height={64}
|
||||
className="object-cover"
|
||||
onError={(e) => {
|
||||
// Заменяем битые изображения на заглушку
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.onerror = null;
|
||||
target.src = '/images/no-image.svg';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-bold text-sm mb-2 leading-tight">{product.name}</h3>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-md bg-blue-50 text-blue-700 text-xs font-medium flex-shrink-0">
|
||||
ID: {product.articleId}
|
||||
</span>
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full bg-emerald-100 text-emerald-700 text-sm font-bold whitespace-nowrap flex-shrink-0 ml-2">
|
||||
{product.price.toLocaleString()} ₽
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))
|
||||
: null}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Подсказка, что нужно ввести артикулы */}
|
||||
{products.length === 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="text-center text-white/80 flex-1 flex flex-col justify-center items-center p-4"
|
||||
>
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.05, 1],
|
||||
rotate: [0, 2, -2, 0],
|
||||
}}
|
||||
transition={{
|
||||
repeat: Infinity,
|
||||
repeatType: 'mirror',
|
||||
duration: 2,
|
||||
}}
|
||||
>
|
||||
<FiPackage size={40} className="mb-3" />
|
||||
</motion.div>
|
||||
<p className="text-sm">
|
||||
Введите артикулы и нажмите поиск для сравнения товаров
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDisplay;
|
5
scan-sphera-main/src/app/components/index.ts
Normal file
5
scan-sphera-main/src/app/components/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export { default as ProductDisplay } from './ProductDisplay';
|
||||
export { default as CityPositionsTable } from './CityPositionsTable';
|
||||
export { default as PositionChart } from './PositionChart';
|
||||
export { default as HistoryDisplay } from './HistoryDisplay';
|
||||
export { default as Preloader } from './Preloader';
|
177
scan-sphera-main/src/app/globals.css
Normal file
177
scan-sphera-main/src/app/globals.css
Normal file
@ -0,0 +1,177 @@
|
||||
@import 'tailwindcss/preflight';
|
||||
@tailwind utilities;
|
||||
@import 'tailwindcss';
|
||||
|
||||
:root {
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-rgb: 246, 240, 255;
|
||||
--primary-color: rgb(236, 72, 153);
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: linear-gradient(to right, #9da8f9, #fce7ff);
|
||||
min-height: 100vh;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Стили для карточек и элементов */
|
||||
.card-hover {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.input-focus {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.input-focus:focus {
|
||||
box-shadow: 0 0 0 3px rgba(236, 72, 153, 0.3);
|
||||
}
|
||||
|
||||
/* Анимации */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.5s ease-out;
|
||||
}
|
||||
|
||||
.animate-pulse-slow {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
/* Градиенты */
|
||||
.gradient-border {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.gradient-border::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 2px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
rgba(236, 72, 153, 0.7),
|
||||
rgba(168, 85, 247, 0.7)
|
||||
);
|
||||
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.gradient-border:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Добавляем стили для замены классов, которые не доступны в Tailwind v4 */
|
||||
.bg-purple-50 {
|
||||
background-color: rgb(246, 240, 255);
|
||||
}
|
||||
|
||||
.bg-purple-500,
|
||||
.from-purple-500 {
|
||||
background-color: rgb(139, 92, 246);
|
||||
}
|
||||
|
||||
.to-purple-400 {
|
||||
background-color: rgb(167, 139, 250);
|
||||
}
|
||||
|
||||
.bg-pink-500,
|
||||
.via-pink-500 {
|
||||
background-color: rgb(236, 72, 153);
|
||||
}
|
||||
|
||||
.bg-pink-200 {
|
||||
background-color: rgb(251, 207, 232);
|
||||
}
|
||||
|
||||
.bg-pink-300 {
|
||||
background-color: rgb(249, 168, 212);
|
||||
}
|
||||
|
||||
.text-pink-800 {
|
||||
color: rgb(157, 23, 77);
|
||||
}
|
||||
|
||||
.text-purple-600 {
|
||||
color: rgb(124, 58, 237);
|
||||
}
|
||||
|
||||
.hover\:bg-pink-600:hover {
|
||||
background-color: rgb(219, 39, 119);
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.btn-primary {
|
||||
@apply bg-pink-500 text-white font-medium px-4 py-2 rounded-full hover:bg-pink-600 transition-colors;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-xl shadow-sm;
|
||||
}
|
||||
}
|
||||
|
||||
/* Анимация загрузки */
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
29
scan-sphera-main/src/app/layout.tsx
Normal file
29
scan-sphera-main/src/app/layout.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Парсер товаров',
|
||||
description: 'Сервис отслеживания позиций товаров',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="ru">
|
||||
<body
|
||||
className="font-sans antialiased"
|
||||
style={{
|
||||
background: 'linear-gradient(to right, #9DA8F9, #FCE7FF)',
|
||||
height: '100vh',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
47
scan-sphera-main/src/app/lib/history.ts
Normal file
47
scan-sphera-main/src/app/lib/history.ts
Normal file
@ -0,0 +1,47 @@
|
||||
// Тип для элемента истории поиска
|
||||
interface SearchHistoryItem {
|
||||
id: string;
|
||||
date: Date;
|
||||
query: string;
|
||||
myArticleId: string;
|
||||
competitorArticleId: string;
|
||||
}
|
||||
|
||||
// Объявляем как внешнюю переменную, чтобы она была доступна из других модулей
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var searchHistory: SearchHistoryItem[];
|
||||
}
|
||||
|
||||
// Инициализируем историю, если она еще не существует
|
||||
if (!global.searchHistory) {
|
||||
global.searchHistory = [];
|
||||
}
|
||||
|
||||
// Функция для сохранения истории поиска
|
||||
export function saveSearchHistory(
|
||||
query: string,
|
||||
myArticleId: string,
|
||||
competitorArticleId?: string
|
||||
) {
|
||||
// Ограничиваем историю до 10 последних запросов
|
||||
if (global.searchHistory.length >= 10) {
|
||||
global.searchHistory.shift(); // Удаляем самый старый запрос
|
||||
}
|
||||
|
||||
// Добавляем новый запрос
|
||||
global.searchHistory.push({
|
||||
id: Date.now().toString(),
|
||||
date: new Date(),
|
||||
query,
|
||||
myArticleId,
|
||||
competitorArticleId: competitorArticleId || '',
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Сохранен поисковый запрос: ${query}, товары: ${myArticleId}${
|
||||
competitorArticleId ? `, ${competitorArticleId}` : ' (без конкурента)'
|
||||
}`
|
||||
);
|
||||
return true;
|
||||
}
|
16
scan-sphera-main/src/app/lib/prisma.ts
Normal file
16
scan-sphera-main/src/app/lib/prisma.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { PrismaClient } from '../../generated/prisma';
|
||||
|
||||
// PrismaClient является тяжелым для инстанцирования,
|
||||
// поэтому мы глобально сохраняем одиночный экземпляр
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var prisma: PrismaClient | undefined;
|
||||
}
|
||||
|
||||
export const prisma = global.prisma || new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
global.prisma = prisma;
|
||||
}
|
||||
|
||||
export default prisma;
|
367
scan-sphera-main/src/app/page.tsx
Normal file
367
scan-sphera-main/src/app/page.tsx
Normal file
@ -0,0 +1,367 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ProductDisplay,
|
||||
CityPositionsTable,
|
||||
PositionChart,
|
||||
HistoryDisplay,
|
||||
} from './components';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useState, useEffect } from 'react';
|
||||
import ClientWrapper from './ClientWrapper';
|
||||
import {
|
||||
ChartDataPoint,
|
||||
CityPosition,
|
||||
HistoryRecord,
|
||||
SearchResponse,
|
||||
} from '@/types';
|
||||
import { FiBarChart2, FiActivity } from 'react-icons/fi';
|
||||
|
||||
// Data persistence removed – no localStorage interaction
|
||||
|
||||
export default function Home() {
|
||||
// Добавляем состояние для данных
|
||||
const [chartData, setChartData] = useState<ChartDataPoint[]>([]);
|
||||
const [cityPositions, setCityPositions] = useState<CityPosition[]>([]);
|
||||
const [competitorPositions, setCompetitorPositions] = useState<CityPosition[]>([]);
|
||||
const [historyData, setHistoryData] = useState<HistoryRecord[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchPerformed, setSearchPerformed] = useState(false);
|
||||
const [currentQuery, setCurrentQuery] = useState(''); // Текущий поисковый запрос
|
||||
const [currentArticleId, setCurrentArticleId] = useState(''); // Текущий артикул товара
|
||||
const [currentCompetitorId, setCurrentCompetitorId] = useState(''); // Текущий артикул конкурента
|
||||
const [isComparisonMode, setIsComparisonMode] = useState(false); // Режим сравнения
|
||||
|
||||
// Helper to reset state when new search starts
|
||||
const resetDataState = () => {
|
||||
setChartData([]);
|
||||
setCityPositions([]);
|
||||
setCompetitorPositions([]);
|
||||
setSearchPerformed(false);
|
||||
setIsComparisonMode(false);
|
||||
};
|
||||
|
||||
// Загрузка начальных данных истории (без localStorage)
|
||||
useEffect(() => {
|
||||
fetchHistory();
|
||||
}, []);
|
||||
|
||||
// Получение данных истории
|
||||
const fetchHistory = async (queryOverride?: string, articleOverride?: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Параметры запроса для фильтрации истории
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Фильтруем строго по конкретному артикулу и по конкретному запросу
|
||||
const articleId = articleOverride || currentArticleId;
|
||||
const query = queryOverride || currentQuery;
|
||||
|
||||
if (articleId) {
|
||||
params.append('articleId', articleId);
|
||||
}
|
||||
|
||||
// Обязательно фильтруем по точному запросу (ключевые слова)
|
||||
if (query) {
|
||||
params.append('query', query);
|
||||
}
|
||||
|
||||
const queryString = params.toString() ? `?${params.toString()}` : '';
|
||||
const response = await fetch(`/api/history${queryString}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка при загрузке истории');
|
||||
}
|
||||
const data = await response.json();
|
||||
setHistoryData(data);
|
||||
|
||||
// Если есть история, берем последнюю запись для отображения
|
||||
if (data.length > 0) {
|
||||
updateDisplayData(data[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки истории:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Обновление данных для отображения
|
||||
const updateDisplayData = (historyItem: HistoryRecord) => {
|
||||
// Обновляем графические данные
|
||||
const chartDataFromHistory = historyItem.positions.map((pos) => ({
|
||||
city: pos.city,
|
||||
myPosition:
|
||||
pos.pageRank === 1 ? pos.rank : (pos.pageRank - 1) * 100 + pos.rank,
|
||||
// В истории нет данных конкурента, используем заглушку
|
||||
competitorPosition:
|
||||
pos.competitorRank && pos.competitorPageRank
|
||||
? pos.competitorPageRank === 1
|
||||
? pos.competitorRank
|
||||
: (pos.competitorPageRank - 1) * 100 + pos.competitorRank
|
||||
: 0,
|
||||
}));
|
||||
setChartData(chartDataFromHistory);
|
||||
};
|
||||
|
||||
// Обработчик завершения поиска (обновлено под новую структуру)
|
||||
const handleSearchComplete = (data: SearchResponse, searchQuery?: string) => {
|
||||
// Сбрасываем предыдущие данные
|
||||
resetDataState();
|
||||
|
||||
// Формируем позиции для таблицы - берем позиции основного товара
|
||||
const myArticleId = data.myArticleId;
|
||||
const myPositions = data.positions[myArticleId] || [];
|
||||
|
||||
setCityPositions(myPositions);
|
||||
|
||||
// Определяем режим сравнения и данные конкурента
|
||||
const hasCompetitor = !!(data.competitorArticleId && data.positions[data.competitorArticleId]);
|
||||
setIsComparisonMode(hasCompetitor);
|
||||
setCurrentCompetitorId(data.competitorArticleId || '');
|
||||
|
||||
if (hasCompetitor) {
|
||||
const competitorPositions = data.positions[data.competitorArticleId!] || [];
|
||||
setCompetitorPositions(competitorPositions);
|
||||
|
||||
// Создаем chartData для сравнения с конкурентом
|
||||
const chartDataFromResponse: ChartDataPoint[] = myPositions.map(myPos => {
|
||||
const competitorPos = competitorPositions.find(cp => cp.city === myPos.city);
|
||||
return {
|
||||
city: myPos.city,
|
||||
myPosition: myPos.position ?? 0,
|
||||
competitorPosition: competitorPos?.position ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
setChartData(chartDataFromResponse);
|
||||
|
||||
// Save to localStorage (removed)
|
||||
} else {
|
||||
// Режим без конкурента - показываем только мои позиции
|
||||
setCompetitorPositions([]);
|
||||
const chartDataFromResponse: ChartDataPoint[] = myPositions.map(myPos => ({
|
||||
city: myPos.city,
|
||||
myPosition: myPos.position ?? 0,
|
||||
competitorPosition: 0,
|
||||
}));
|
||||
|
||||
setChartData(chartDataFromResponse);
|
||||
|
||||
// Save to localStorage (removed)
|
||||
}
|
||||
|
||||
setSearchPerformed(true);
|
||||
|
||||
// Сохраняем артикул основного товара для фильтрации истории
|
||||
if (data.products && data.products.length > 0) {
|
||||
const mainProduct = data.products.find(p => p.article === myArticleId);
|
||||
if (mainProduct) {
|
||||
// Если сменился артикул, очищаем историю
|
||||
if (currentArticleId !== mainProduct.article) {
|
||||
setHistoryData([]);
|
||||
}
|
||||
setCurrentArticleId(mainProduct.article);
|
||||
|
||||
// Save query and article ID (removed)
|
||||
if (searchQuery) setCurrentQuery(searchQuery);
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем историю после выполнения поиска, чтобы отобразить новые данные
|
||||
// Устанавливаем небольшую задержку, чтобы данные успели сохраниться в БД
|
||||
setTimeout(() => {
|
||||
fetchHistory();
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// Анимационные варианты
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.2,
|
||||
delayChildren: 0.3,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
type: 'spring',
|
||||
stiffness: 80,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Заглушки для пустых состояний
|
||||
const TablePlaceholder = () => (
|
||||
<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 items-center justify-center">
|
||||
<FiBarChart2 size={48} className="text-purple-400 mb-4" />
|
||||
<p className="text-gray-600 text-center mb-2 font-medium">
|
||||
Таблица позиций
|
||||
</p>
|
||||
<p className="text-gray-500 text-sm text-center">
|
||||
Данные о позициях товаров по городам будут отображаться здесь
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ChartPlaceholder = () => (
|
||||
<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 items-center justify-center">
|
||||
<FiActivity size={48} className="text-purple-400 mb-4" />
|
||||
<p className="text-gray-600 text-center mb-2 font-medium">
|
||||
График позиций
|
||||
</p>
|
||||
<p className="text-gray-500 text-sm text-center">
|
||||
Визуализация позиций товаров будет отображаться здесь
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ClientWrapper>
|
||||
<motion.main
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={containerVariants}
|
||||
className="min-h-screen overflow-auto p-2 md:p-3"
|
||||
>
|
||||
<div className="max-w-full mx-auto">
|
||||
{/* Data Management Controls removed (no persistence) */}
|
||||
|
||||
{/* Mobile Layout */}
|
||||
<div className="flex flex-col gap-3 pb-6 lg:hidden overflow-hidden">
|
||||
{/* Mobile: Search section */}
|
||||
<motion.div
|
||||
className="min-h-[180px] max-h-[250px] flex-shrink-0"
|
||||
variants={itemVariants}
|
||||
>
|
||||
<ProductDisplay
|
||||
products={[]}
|
||||
onSearchComplete={handleSearchComplete}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Mobile: Table section */}
|
||||
<motion.div
|
||||
className="min-h-[200px] max-h-[300px] flex-shrink-0"
|
||||
variants={itemVariants}
|
||||
>
|
||||
{searchPerformed ? (
|
||||
<CityPositionsTable
|
||||
positions={cityPositions}
|
||||
competitorPositions={competitorPositions}
|
||||
isComparisonMode={isComparisonMode}
|
||||
myArticleId={currentArticleId}
|
||||
competitorArticleId={currentCompetitorId}
|
||||
/>
|
||||
) : (
|
||||
<TablePlaceholder />
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Mobile: Chart section */}
|
||||
<motion.div
|
||||
className="min-h-[250px] max-h-[350px] flex-shrink-0"
|
||||
variants={itemVariants}
|
||||
>
|
||||
{searchPerformed ? (
|
||||
<PositionChart
|
||||
data={chartData}
|
||||
myArticleId={currentArticleId}
|
||||
competitorArticleId={currentCompetitorId}
|
||||
/>
|
||||
) : (
|
||||
<ChartPlaceholder />
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Mobile: History section */}
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="min-h-[150px] max-h-[250px] flex-shrink-0 mb-6"
|
||||
>
|
||||
<HistoryDisplay
|
||||
history={historyData}
|
||||
isLoading={isLoading}
|
||||
searchPerformed={searchPerformed}
|
||||
currentQuery={currentQuery}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Layout */}
|
||||
<div className="hidden lg:grid lg:grid-rows-[1fr_auto] lg:gap-3 min-h-screen">
|
||||
{/* Desktop: Top section with three columns */}
|
||||
<div className="grid lg:grid-cols-12 gap-3 min-h-[600px]">
|
||||
{/* Товары и поиск - 3/12 */}
|
||||
<motion.div
|
||||
className="lg:col-span-3 h-full"
|
||||
variants={itemVariants}
|
||||
>
|
||||
<ProductDisplay
|
||||
products={[]}
|
||||
onSearchComplete={handleSearchComplete}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Таблица - 3/12 */}
|
||||
<motion.div
|
||||
className="lg:col-span-3 h-full"
|
||||
variants={itemVariants}
|
||||
>
|
||||
{searchPerformed ? (
|
||||
<CityPositionsTable
|
||||
positions={cityPositions}
|
||||
competitorPositions={competitorPositions}
|
||||
isComparisonMode={isComparisonMode}
|
||||
myArticleId={currentArticleId}
|
||||
competitorArticleId={currentCompetitorId}
|
||||
/>
|
||||
) : (
|
||||
<TablePlaceholder />
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* График - 6/12 */}
|
||||
<motion.div
|
||||
className="lg:col-span-6 h-full"
|
||||
variants={itemVariants}
|
||||
>
|
||||
{searchPerformed ? (
|
||||
<PositionChart
|
||||
data={chartData}
|
||||
myArticleId={currentArticleId}
|
||||
competitorArticleId={currentCompetitorId}
|
||||
/>
|
||||
) : (
|
||||
<ChartPlaceholder />
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Bottom section - History */}
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="min-h-[300px] max-h-[400px] overflow-hidden"
|
||||
>
|
||||
<HistoryDisplay
|
||||
history={historyData}
|
||||
isLoading={isLoading}
|
||||
searchPerformed={searchPerformed}
|
||||
currentQuery={currentQuery}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.main>
|
||||
</ClientWrapper>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user