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