diff --git a/src/app/api/parser/route.ts b/src/app/api/parser/route.ts index 7746b49..39fd4a4 100644 --- a/src/app/api/parser/route.ts +++ b/src/app/api/parser/route.ts @@ -19,6 +19,55 @@ const pseudoRandomInRange = (key: string, min: number, max: number) => { export const maxDuration = 300; // 300 секунд = 5 минут export const dynamic = 'force-dynamic'; +// Унифицированный ответ JSON с отключением буферизации (важно для Nginx) +const jsonNoBuffering = (data: any, init?: { status?: number; headers?: HeadersInit }) => { + const headers = new Headers(init?.headers || {}); + // Отключаем прокси-буферизацию и просим не кэшировать + headers.set('X-Accel-Buffering', 'no'); + headers.set('Cache-Control', 'no-store'); + return NextResponse.json(data, { status: init?.status, headers }); +}; + +// Потоковый JSON-ответ с «пульсом», чтобы прокси не роняли соединение по таймауту +// Отправляет пробел каждые 15с, затем итоговый JSON и закрывает поток +const streamJsonWithHeartbeat = (work: () => Promise) => { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + const heartbeat = setInterval(() => { + // Отправляем «пинг», чтобы прокси видели активность апстрима + controller.enqueue(encoder.encode(' ')); + }, 15000); + + work() + .then((result) => { + controller.enqueue(encoder.encode(JSON.stringify(result))); + clearInterval(heartbeat); + controller.close(); + }) + .catch((err) => { + const errorPayload = { + error: 'Внутренняя ошибка во время обработки запроса', + details: String(err), + }; + controller.enqueue(encoder.encode(JSON.stringify(errorPayload))); + clearInterval(heartbeat); + controller.close(); + }); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'X-Accel-Buffering': 'no', // важно для Nginx, чтобы не буферизировал стрим + 'Cache-Control': 'no-store', + 'Connection': 'keep-alive', + // Поддержка chunked по умолчанию (без Content-Length) + }, + }); +}; + // Функция для расчета реальной позиции с учетом страницы const calculatePosition = ( position: number | string, @@ -54,7 +103,7 @@ export async function POST(req: NextRequest) { // Проверяем наличие обязательных параметров if (!query || !myArticleId) { - return NextResponse.json( + return jsonNoBuffering( { error: 'Не указаны все обязательные поля (запрос и артикул)' }, { status: 400 } ); @@ -256,157 +305,141 @@ export async function POST(req: NextRequest) { }; // Возвращаем результаты из БД - return NextResponse.json(responseData); + return jsonNoBuffering(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); + // Выполняем тяжелую работу в фоне и стримим «пульс» в ответ + const work = async (): Promise => { + // Устанавливаем предупреждение, если запрос очень долгий + const apiTimeout = setTimeout(() => { + console.log('Предупреждение: API запрос выполняется дольше ожидаемого, но продолжает работу'); + }, 90000); - // Сохраняем историю запроса в локальное хранилище - saveSearchHistory(query, myArticleId, competitorArticleId); - - // Сохраняем данные в PostgreSQL try { - // Создаем запись о поисковом запросе - const searchQuery = await prisma.searchQuery.create({ - data: { - query: query, - }, - }); + // Выполняем парсинг с расширенными возможностями + const result = await parseWildberries( + query, + myArticleId, + competitorArticleId, + true, // Всегда используем расширенный парсинг + maxItems + ); - // Сохраняем данные о всех товарах - 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, - }, - }); - } + clearTimeout(apiTimeout); - // Сохраняем данные о позициях для всех товаров - for (const [articleId, cityPositions] of Object.entries(result.positions)) { - const productDB = await prisma.product.findUnique({ - where: { article: articleId }, + // Сохраняем историю запроса в локальное хранилище + saveSearchHistory(query, myArticleId, competitorArticleId); + + // Сохраняем данные в PostgreSQL (не блокируем общий ответ в случае ошибок) + try { + const searchQuery = await prisma.searchQuery.create({ + data: { query }, }); - if (productDB && cityPositions.length > 0) { - for (const posData of cityPositions) { - const position = posData.position; + 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, + }, + }); + } - if (position && position > 0) { - const page = Math.ceil(position / 100); - - await prisma.position.create({ - data: { - city: posData.city, - position: position, - page: page, - productId: productDB.id, - 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 / 100); + await prisma.position.create({ + data: { + city: posData.city, + position, + page, + productId: productDB.id, + searchQueryId: searchQuery.id, + }, + }); + } + } } } + + console.log('Данные успешно сохранены в базе данных'); + } catch (dbError) { + console.error('Ошибка при сохранении в базу данных:', dbError); } - console.log(`Данные успешно сохранены в базе данных`); - } catch (dbError) { - console.error('Ошибка при сохранении в базу данных:', dbError); - // Продолжаем выполнение, даже если сохранение в БД не удалось - } + if (!result || !result.products || result.products.length === 0) { + console.error('Парсер вернул пустой результат'); + return { + products: [ + { + name: 'Ошибка при получении данных', + brand: 'Н/Д', + price: 0, + article: myArticleId, + imageUrl: '/images/no-image.svg', + }, + ], + positions: { [myArticleId]: [] }, + myArticleId, + competitorArticleId, + } satisfies SearchResponse; + } - // Проверяем структуру результата перед возвратом - 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', - }], + return { + products: result.products, + positions: result.positions, + myArticleId, + competitorArticleId, + } satisfies SearchResponse; + } catch (parsingError) { + clearTimeout(apiTimeout); + console.error('Ошибка при парсинге:', parsingError); + return { + products: [ + { + name: 'Ошибка при получении данных', + brand: 'Н/Д', + price: 0, + article: myArticleId, + imageUrl: '/images/no-image.svg', + }, + ], positions: { [myArticleId]: [] }, - myArticleId: myArticleId, - competitorArticleId: competitorArticleId, - }; - return NextResponse.json(fallbackResponse); + myArticleId, + competitorArticleId, + } satisfies SearchResponse; } + }; - // Формируем ответ в новом формате - 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 }); - } + return streamJsonWithHeartbeat(work); } catch (error) { console.error('Ошибка при парсинге:', error); - return NextResponse.json( + return jsonNoBuffering( { error: 'Ошибка при парсинге данных', details: String(error) }, { status: 500 } ); } } catch (error) { console.error('Ошибка при обработке запроса:', error); - return NextResponse.json( + return jsonNoBuffering( { error: 'Внутренняя ошибка сервера', details: String(error) }, { status: 500 } );