api(parser): stream heartbeat to avoid 504s; disable proxy buffering headers
This commit is contained in:
@ -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,157 +305,141 @@ export async function POST(req: NextRequest) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Возвращаем результаты из БД
|
// Возвращаем результаты из БД
|
||||||
return NextResponse.json(responseData);
|
return jsonNoBuffering(responseData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если в кэше нет данных, делаем новый запрос к Wildberries
|
// Если в кэше нет данных, делаем новый запрос к Wildberries
|
||||||
console.log('Кэшированных данных нет, выполняем парсинг Wildberries');
|
console.log('Кэшированных данных нет, выполняем парсинг Wildberries');
|
||||||
|
// Выполняем тяжелую работу в фоне и стримим «пульс» в ответ
|
||||||
|
const work = async (): Promise<SearchResponse> => {
|
||||||
|
// Устанавливаем предупреждение, если запрос очень долгий
|
||||||
|
const apiTimeout = setTimeout(() => {
|
||||||
|
console.log('Предупреждение: API запрос выполняется дольше ожидаемого, но продолжает работу');
|
||||||
|
}, 90000);
|
||||||
|
|
||||||
// Устанавливаем таймаут для ответа 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 {
|
try {
|
||||||
// Создаем запись о поисковом запросе
|
// Выполняем парсинг с расширенными возможностями
|
||||||
const searchQuery = await prisma.searchQuery.create({
|
const result = await parseWildberries(
|
||||||
data: {
|
query,
|
||||||
query: query,
|
myArticleId,
|
||||||
},
|
competitorArticleId,
|
||||||
});
|
true, // Всегда используем расширенный парсинг
|
||||||
|
maxItems
|
||||||
|
);
|
||||||
|
|
||||||
// Сохраняем данные о всех товарах
|
clearTimeout(apiTimeout);
|
||||||
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)) {
|
saveSearchHistory(query, myArticleId, competitorArticleId);
|
||||||
const productDB = await prisma.product.findUnique({
|
|
||||||
where: { article: articleId },
|
// Сохраняем данные в PostgreSQL (не блокируем общий ответ в случае ошибок)
|
||||||
|
try {
|
||||||
|
const searchQuery = await prisma.searchQuery.create({
|
||||||
|
data: { query },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (productDB && cityPositions.length > 0) {
|
for (const product of result.products) {
|
||||||
for (const posData of cityPositions) {
|
await prisma.product.upsert({
|
||||||
const position = posData.position;
|
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) {
|
for (const [articleId, cityPositions] of Object.entries(result.positions)) {
|
||||||
const page = Math.ceil(position / 100);
|
const productDB = await prisma.product.findUnique({ where: { article: articleId } });
|
||||||
|
if (productDB && cityPositions.length > 0) {
|
||||||
await prisma.position.create({
|
for (const posData of cityPositions) {
|
||||||
data: {
|
const position = posData.position;
|
||||||
city: posData.city,
|
if (position && position > 0) {
|
||||||
position: position,
|
const page = Math.ceil(position / 100);
|
||||||
page: page,
|
await prisma.position.create({
|
||||||
productId: productDB.id,
|
data: {
|
||||||
searchQueryId: searchQuery.id,
|
city: posData.city,
|
||||||
},
|
position,
|
||||||
});
|
page,
|
||||||
}
|
productId: productDB.id,
|
||||||
|
searchQueryId: searchQuery.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('Данные успешно сохранены в базе данных');
|
||||||
|
} catch (dbError) {
|
||||||
|
console.error('Ошибка при сохранении в базу данных:', dbError);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Данные успешно сохранены в базе данных`);
|
if (!result || !result.products || result.products.length === 0) {
|
||||||
} catch (dbError) {
|
console.error('Парсер вернул пустой результат');
|
||||||
console.error('Ошибка при сохранении в базу данных:', dbError);
|
return {
|
||||||
// Продолжаем выполнение, даже если сохранение в БД не удалось
|
products: [
|
||||||
}
|
{
|
||||||
|
name: 'Ошибка при получении данных',
|
||||||
|
brand: 'Н/Д',
|
||||||
|
price: 0,
|
||||||
|
article: myArticleId,
|
||||||
|
imageUrl: '/images/no-image.svg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
positions: { [myArticleId]: [] },
|
||||||
|
myArticleId,
|
||||||
|
competitorArticleId,
|
||||||
|
} satisfies SearchResponse;
|
||||||
|
}
|
||||||
|
|
||||||
// Проверяем структуру результата перед возвратом
|
return {
|
||||||
if (!result || !result.products || result.products.length === 0) {
|
products: result.products,
|
||||||
console.error('Парсер вернул пустой результат');
|
positions: result.positions,
|
||||||
const fallbackResponse: SearchResponse = {
|
myArticleId,
|
||||||
products: [{
|
competitorArticleId,
|
||||||
name: 'Ошибка при получении данных',
|
} satisfies SearchResponse;
|
||||||
brand: 'Н/Д',
|
} catch (parsingError) {
|
||||||
price: 0,
|
clearTimeout(apiTimeout);
|
||||||
article: myArticleId,
|
console.error('Ошибка при парсинге:', parsingError);
|
||||||
imageUrl: '/images/no-image.svg',
|
return {
|
||||||
}],
|
products: [
|
||||||
|
{
|
||||||
|
name: 'Ошибка при получении данных',
|
||||||
|
brand: 'Н/Д',
|
||||||
|
price: 0,
|
||||||
|
article: myArticleId,
|
||||||
|
imageUrl: '/images/no-image.svg',
|
||||||
|
},
|
||||||
|
],
|
||||||
positions: { [myArticleId]: [] },
|
positions: { [myArticleId]: [] },
|
||||||
myArticleId: myArticleId,
|
myArticleId,
|
||||||
competitorArticleId: competitorArticleId,
|
competitorArticleId,
|
||||||
};
|
} satisfies SearchResponse;
|
||||||
return NextResponse.json(fallbackResponse);
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Формируем ответ в новом формате
|
return streamJsonWithHeartbeat(work);
|
||||||
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) {
|
} 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 }
|
||||||
);
|
);
|
||||||
|
Reference in New Issue
Block a user