api(parser): stream heartbeat to avoid 504s; disable proxy buffering headers

This commit is contained in:
Bivekich
2025-09-01 22:29:38 +03:00
parent 6e3d51c1e9
commit 82828dfaf3

View File

@ -19,6 +19,55 @@ const pseudoRandomInRange = (key: string, min: number, max: number) => {
export const maxDuration = 300; // 300 секунд = 5 минут export const maxDuration = 300; // 300 секунд = 5 минут
export const dynamic = 'force-dynamic'; 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 = <T>(work: () => Promise<T>) => {
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
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 = ( const calculatePosition = (
position: number | string, position: number | string,
@ -54,7 +103,7 @@ export async function POST(req: NextRequest) {
// Проверяем наличие обязательных параметров // Проверяем наличие обязательных параметров
if (!query || !myArticleId) { if (!query || !myArticleId) {
return NextResponse.json( return jsonNoBuffering(
{ error: 'Не указаны все обязательные поля (запрос и артикул)' }, { error: 'Не указаны все обязательные поля (запрос и артикул)' },
{ status: 400 } { status: 400 }
); );
@ -256,17 +305,18 @@ export async function POST(req: NextRequest) {
}; };
// Возвращаем результаты из БД // Возвращаем результаты из БД
return NextResponse.json(responseData); return jsonNoBuffering(responseData);
} }
} }
// Если в кэше нет данных, делаем новый запрос к Wildberries // Если в кэше нет данных, делаем новый запрос к Wildberries
console.log('Кэшированных данных нет, выполняем парсинг Wildberries'); console.log('Кэшированных данных нет, выполняем парсинг Wildberries');
// Выполняем тяжелую работу в фоне и стримим «пульс» в ответ
// Устанавливаем таймаут для ответа API (всегда используем увеличенный таймаут) const work = async (): Promise<SearchResponse> => {
// Устанавливаем предупреждение, если запрос очень долгий
const apiTimeout = setTimeout(() => { const apiTimeout = setTimeout(() => {
console.log('Предупреждение: API запрос выполняется дольше ожидаемого, но продолжает работу'); console.log('Предупреждение: API запрос выполняется дольше ожидаемого, но продолжает работу');
}, 90000); // 90 секунд для поиска больших объемов товаров }, 90000);
try { try {
// Выполняем парсинг с расширенными возможностями // Выполняем парсинг с расширенными возможностями
@ -278,22 +328,17 @@ export async function POST(req: NextRequest) {
maxItems maxItems
); );
// Очищаем таймаут
clearTimeout(apiTimeout); clearTimeout(apiTimeout);
// Сохраняем историю запроса в локальное хранилище // Сохраняем историю запроса в локальное хранилище
saveSearchHistory(query, myArticleId, competitorArticleId); saveSearchHistory(query, myArticleId, competitorArticleId);
// Сохраняем данные в PostgreSQL // Сохраняем данные в PostgreSQL (не блокируем общий ответ в случае ошибок)
try { try {
// Создаем запись о поисковом запросе
const searchQuery = await prisma.searchQuery.create({ const searchQuery = await prisma.searchQuery.create({
data: { data: { query },
query: query,
},
}); });
// Сохраняем данные о всех товарах
for (const product of result.products) { for (const product of result.products) {
await prisma.product.upsert({ await prisma.product.upsert({
where: { article: product.article }, where: { article: product.article },
@ -314,24 +359,18 @@ export async function POST(req: NextRequest) {
}); });
} }
// Сохраняем данные о позициях для всех товаров
for (const [articleId, cityPositions] of Object.entries(result.positions)) { for (const [articleId, cityPositions] of Object.entries(result.positions)) {
const productDB = await prisma.product.findUnique({ const productDB = await prisma.product.findUnique({ where: { article: articleId } });
where: { article: articleId },
});
if (productDB && cityPositions.length > 0) { if (productDB && cityPositions.length > 0) {
for (const posData of cityPositions) { for (const posData of cityPositions) {
const position = posData.position; const position = posData.position;
if (position && position > 0) { if (position && position > 0) {
const page = Math.ceil(position / 100); const page = Math.ceil(position / 100);
await prisma.position.create({ await prisma.position.create({
data: { data: {
city: posData.city, city: posData.city,
position: position, position,
page: page, page,
productId: productDB.id, productId: productDB.id,
searchQueryId: searchQuery.id, searchQueryId: searchQuery.id,
}, },
@ -341,72 +380,66 @@ export async function POST(req: NextRequest) {
} }
} }
console.log(`Данные успешно сохранены в базе данных`); console.log('Данные успешно сохранены в базе данных');
} catch (dbError) { } catch (dbError) {
console.error('Ошибка при сохранении в базу данных:', dbError); console.error('Ошибка при сохранении в базу данных:', dbError);
// Продолжаем выполнение, даже если сохранение в БД не удалось
} }
// Проверяем структуру результата перед возвратом
if (!result || !result.products || result.products.length === 0) { if (!result || !result.products || result.products.length === 0) {
console.error('Парсер вернул пустой результат'); console.error('Парсер вернул пустой результат');
const fallbackResponse: SearchResponse = { return {
products: [{ products: [
{
name: 'Ошибка при получении данных', name: 'Ошибка при получении данных',
brand: 'Н/Д', brand: 'Н/Д',
price: 0, price: 0,
article: myArticleId, article: myArticleId,
imageUrl: '/images/no-image.svg', imageUrl: '/images/no-image.svg',
}], },
],
positions: { [myArticleId]: [] }, positions: { [myArticleId]: [] },
myArticleId: myArticleId, myArticleId,
competitorArticleId: competitorArticleId, competitorArticleId,
}; } satisfies SearchResponse;
return NextResponse.json(fallbackResponse);
} }
// Формируем ответ в новом формате return {
const responseData: SearchResponse = {
products: result.products, products: result.products,
positions: result.positions, positions: result.positions,
myArticleId: myArticleId, myArticleId,
competitorArticleId: competitorArticleId, competitorArticleId,
}; } satisfies SearchResponse;
// Возвращаем результат
return NextResponse.json(responseData);
} catch (parsingError) { } catch (parsingError) {
// Очищаем таймаут
clearTimeout(apiTimeout); clearTimeout(apiTimeout);
console.error('Ошибка при парсинге:', parsingError); console.error('Ошибка при парсинге:', parsingError);
return {
// Создаем базовый ответ с сообщением об ошибке в новом формате products: [
const errorResponse: SearchResponse = { {
products: [{
name: 'Ошибка при получении данных', name: 'Ошибка при получении данных',
brand: 'Н/Д', brand: 'Н/Д',
price: 0, price: 0,
article: myArticleId, article: myArticleId,
imageUrl: '/images/no-image.svg', imageUrl: '/images/no-image.svg',
}], },
],
positions: { [myArticleId]: [] }, positions: { [myArticleId]: [] },
myArticleId: myArticleId, myArticleId,
competitorArticleId: competitorArticleId, competitorArticleId,
} satisfies SearchResponse;
}
}; };
return NextResponse.json(errorResponse, { status: 500 }); return streamJsonWithHeartbeat(work);
}
} catch (error) { } catch (error) {
console.error('Ошибка при парсинге:', error); console.error('Ошибка при парсинге:', error);
return NextResponse.json( return jsonNoBuffering(
{ error: 'Ошибка при парсинге данных', details: String(error) }, { error: 'Ошибка при парсинге данных', details: String(error) },
{ status: 500 } { status: 500 }
); );
} }
} catch (error) { } catch (error) {
console.error('Ошибка при обработке запроса:', error); console.error('Ошибка при обработке запроса:', error);
return NextResponse.json( return jsonNoBuffering(
{ error: 'Внутренняя ошибка сервера', details: String(error) }, { error: 'Внутренняя ошибка сервера', details: String(error) },
{ status: 500 } { status: 500 }
); );