feat: Telegram Mini App integration and UX improvements

- Добавлена полная интеграция с Telegram Mini App (динамическая загрузка SDK)
- Отдельный компактный дизайн для Telegram Mini App
- Добавлен loader при инициализации (предотвращает мелькание SMS-авторизации)
- Улучшена навигация: кнопки "Назад" и "К списку заявок" теперь сохраняют авторизацию
- Telegram Mini App: кнопка "Выход" просто закрывает приложение
- Telegram Mini App: заявки "В работе" скрыты из списка
- Веб-версия: для заявок "В работе" добавлена кнопка "Просмотреть в Telegram" (ссылка на @klientprav_bot)
- Telegram Mini App: кнопки действий в черновиках расположены вертикально
- Веб-версия: убрано отображение номера телефона в приветствии
- Исправлена проблема с возвратом к списку черновиков (не требует повторной SMS-авторизации)
- Заблокировано удаление и редактирование заявок со статусом "В работе"
- Добавлена документация по Telegram Mini App интеграции
This commit is contained in:
AI Assistant
2026-01-29 16:12:48 +03:00
parent 73524465fd
commit 2e45786e46
57 changed files with 6776 additions and 234 deletions

View File

@@ -0,0 +1,68 @@
#!/bin/bash
# ============================================================================
# Пример curl запроса для Browserless (HTML → PDF)
# Используйте этот запрос в HTTP Request ноде n8n
# ============================================================================
# ВАРИАНТ 1: С data URL (HTML в base64)
curl -X POST http://147.45.146.17:3000/pdf \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"url": "data:text/html;base64,PCFET0NUWVBFIGh0bWw+PGh0bWw+PGJvZHk+PGgxPlRlc3Q8L2gxPjwvYm9keT48L2h0bWw+",
"options": {
"format": "A4",
"printBackground": true,
"margin": {
"top": "20mm",
"right": "15mm",
"bottom": "20mm",
"left": "15mm"
}
}
}'
# ============================================================================
# ВАРИАНТ 2: С прямым HTML (если Browserless поддерживает)
# ============================================================================
# curl -X POST http://147.45.146.17:3000/pdf \
# -H "Content-Type: application/json" \
# -H "Authorization: Bearer YOUR_TOKEN" \
# -d '{
# "html": "<!DOCTYPE html><html><body><h1>Test</h1></body></html>",
# "options": {
# "format": "A4",
# "printBackground": true,
# "margin": {
# "top": "20mm",
# "right": "15mm",
# "bottom": "20mm",
# "left": "15mm"
# }
# }
# }'
# ============================================================================
# НАСТРОЙКА В HTTP REQUEST НОДЕ:
# ============================================================================
# Method: POST
# URL: http://147.45.146.17:3000/pdf
# Headers:
# Content-Type: application/json
# Authorization: Bearer YOUR_TOKEN (если требуется)
# Body (JSON):
# {
# "url": "data:text/html;base64,{{ $json.html_base64_encoded }}",
# "options": {
# "format": "A4",
# "printBackground": true,
# "margin": {
# "top": "20mm",
# "right": "15mm",
# "bottom": "20mm",
# "left": "15mm"
# }
# }
# }
# Response Format: Binary
# ============================================================================

View File

@@ -0,0 +1,163 @@
# Настройка HTTP Request для Browserless Function API
## Готовые настройки для HTTP Request ноды
### Method
`POST`
### URL
```
http://147.45.146.17:3000/function?token=9ahhnpjkchxtcho9
```
### Headers
```json
{
"Content-Type": "application/javascript"
}
```
### Body (Raw)
**Content Type:** `application/javascript`
**Body:**
```javascript
export default async function ({ page }) {
const html = `{{ $json.html }}`;
if (!html) {
throw new Error('❌ HTML не передан');
}
// универсальный sleep
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
await page.setViewport({ width: 1240, height: 1754 });
// Загружаем HTML напрямую
await page.setContent(html, {
waitUntil: ['load', 'domcontentloaded', 'networkidle0'],
});
// Даём браузеру применить стили
await sleep(300);
const pdfBuffer = await page.pdf({
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm',
},
});
return {
status: 'success',
pdf_base64: pdfBuffer.toString('base64'),
size_bytes: pdfBuffer.length,
};
}
```
### Options
- **Timeout:** `40000` (40 секунд)
### Response Format
`JSON` (Browserless вернёт JSON с `pdf_base64`)
---
## Вариант с html_base64
Если у вас HTML в base64, используйте этот вариант:
```javascript
export default async function ({ page }) {
// Получаем HTML из base64
const htmlBase64 = `{{ $json.html_base64 }}`;
const html = Buffer.from(htmlBase64, 'base64').toString('utf8');
if (!html) {
throw new Error('❌ HTML не передан');
}
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
await page.setViewport({ width: 1240, height: 1754 });
await page.setContent(html, {
waitUntil: ['load', 'domcontentloaded', 'networkidle0'],
});
await sleep(300);
const pdfBuffer = await page.pdf({
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm',
},
});
return {
status: 'success',
pdf_base64: pdfBuffer.toString('base64'),
size_bytes: pdfBuffer.length,
};
}
```
---
## Полный Workflow
```
[Code: Process Flights Data] ← Генерирует HTML
[HTTP Request: Browserless Function] ← Используйте настройки выше
[Code: Extract PDF Base64] ← Если нужно обработать ответ
```
---
## Code Node: Extract PDF Base64 (опционально)
Если Browserless уже вернул `pdf_base64` в JSON, можно просто передать дальше:
```javascript
const response = $input.first().json;
return [{
json: {
pdf_base64: response.pdf_base64,
pdf_size_bytes: response.size_bytes,
pdf_size_mb: (response.size_bytes / (1024 * 1024)).toFixed(2),
status: response.status,
success: true
}
}];
```
---
## Преимущества этого подхода
**Прямая работа с HTML** - не нужно конвертировать в data URL
**Полный контроль** - можете добавить любую логику в функцию
**Готовый base64** - Browserless сразу возвращает base64 PDF
**Надёжность** - sleep даёт время браузеру применить стили
---
## Отладка
Если получаете ошибки:
- **"HTML не передан"** → Проверьте, что предыдущая нода вернула `html` или `html_base64`
- **Timeout** → Увеличьте timeout в Options до 60000 (60 секунд)
- **Пустой PDF** → Увеличьте sleep до 500-1000ms

View File

@@ -0,0 +1,29 @@
{
"nodes": [
{
"parameters": {
"method": "POST",
"url": "http://147.45.146.17:3000/function?token=9ahhnpjkchxtcho9",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/javascript"
}
]
},
"sendBody": true,
"contentType": "raw",
"rawContentType": "application/javascript",
"body": "export default async function ({ page }) {\n const html = `{{ $json.html }}`;\n\n if (!html) {\n throw new Error('❌ HTML не передан');\n }\n\n // универсальный sleep\n const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));\n\n await page.setViewport({ width: 1240, height: 1754 });\n\n // Загружаем HTML напрямую\n await page.setContent(html, {\n waitUntil: ['load', 'domcontentloaded', 'networkidle0'],\n });\n\n // Даём браузеру применить стили\n await sleep(300);\n\n const pdfBuffer = await page.pdf({\n format: 'A4',\n printBackground: true,\n margin: {\n top: '20mm',\n right: '15mm',\n bottom: '20mm',\n left: '15mm',\n },\n });\n\n return {\n status: 'success',\n pdf_base64: pdfBuffer.toString('base64'),\n size_bytes: pdfBuffer.length,\n };\n}",
"options": {
"timeout": 40000
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"name": "Browserless: HTML to PDF"
}
]
}

View File

@@ -0,0 +1,135 @@
# Настройка HTTP Request ноды для Browserless
## Готовый запрос для вставки
### Вариант 1: С использованием html_base64 из предыдущей ноды
**Method:** `POST`
**URL:** `http://147.45.146.17:3000/pdf`
**Headers:**
```json
{
"Content-Type": "application/json",
"Authorization": "Bearer YOUR_TOKEN"
}
```
*Примечание: Если токен не требуется, уберите строку Authorization*
**Body (JSON):**
```json
{
"url": "data:text/html;base64,{{ $json.html_base64 }}",
"options": {
"format": "A4",
"printBackground": true,
"margin": {
"top": "20mm",
"right": "15mm",
"bottom": "20mm",
"left": "15mm"
}
}
}
```
**Response Format:** `Binary`
---
### Вариант 2: Если у вас HTML в строке (не base64)
**Method:** `POST`
**URL:** `http://147.45.146.17:3000/pdf`
**Headers:**
```json
{
"Content-Type": "application/json",
"Authorization": "Bearer YOUR_TOKEN"
}
```
**Body (JSON):**
```json
{
"html": "{{ $json.html }}",
"options": {
"format": "A4",
"printBackground": true,
"margin": {
"top": "20mm",
"right": "15mm",
"bottom": "20mm",
"left": "15mm"
}
}
}
```
**Response Format:** `Binary`
---
## Полный workflow
```
[Code: Process Flights Data] ← Генерирует HTML
[Code: HTML to Base64] ← Конвертирует HTML в base64 (если нужно)
[HTTP Request: Browserless PDF] ← Используйте настройки выше
[Code: Extract Base64 PDF] ← Конвертирует binary в base64
```
---
## Code Node: HTML to Base64 (если нужно)
Если у вас HTML в строке, а нужен base64 для data URL:
```javascript
const html = $json.html;
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
return [{
json: {
html_base64: htmlBase64,
html: html
}
}];
```
---
## Code Node: Extract Base64 PDF (после HTTP Request)
```javascript
const pdfBinary = $binary.data;
const base64 = Buffer.isBuffer(pdfBinary)
? pdfBinary.toString('base64')
: Buffer.from(pdfBinary).toString('base64');
const sizeBytes = Buffer.from(base64, 'base64').length;
return [{
json: {
pdf_base64: base64,
pdf_size_bytes: sizeBytes,
pdf_size_mb: (sizeBytes / (1024 * 1024)).toFixed(2),
success: true
}
}];
```
---
## Отладка
Если получаете ошибку:
- **"Bad or missing authentication"** → Проверьте токен или уберите Authorization header
- **"Not Found"** → Проверьте URL эндпоинта
- **Пустой ответ** → Проверьте формат HTML и data URL

View File

@@ -0,0 +1,698 @@
// ============================================================================
// n8n Code Node: Обработка данных о рейсах из FlightAware и FlightRadar24
// ============================================================================
// Объединяет данные из двух источников и формирует красивый HTML для PDF
// ============================================================================
// ==== ПОЛУЧЕНИЕ ВХОДНЫХ ДАННЫХ ====
// Ожидаемая структура: массив с двумя элементами
// [0] - данные из FlightAware (body.flights[])
// [1] - данные из FlightRadar24 (body.data[])
const inputItems = $input.all();
if (!inputItems || inputItems.length === 0) {
return [{
json: {
error: 'Нет входных данных',
html: '<html><body><h1>Ошибка: данные не получены</h1></body></html>',
flights: [],
sources: { flightaware: false, flightradar24: false }
}
}];
}
// ==== ИЗВЛЕЧЕНИЕ ДАННЫХ ИЗ ИСТОЧНИКОВ ====
let flightAwareData = [];
let flightRadar24Data = [];
try {
// Первый элемент - FlightAware
const faItem = inputItems[0];
if (faItem && faItem.json && faItem.json.body && faItem.json.body.flights) {
flightAwareData = Array.isArray(faItem.json.body.flights)
? faItem.json.body.flights
: [];
}
} catch (e) {
console.log('⚠️ Ошибка извлечения FlightAware:', e.message);
}
try {
// Второй элемент - FlightRadar24
const fr24Item = inputItems[1];
if (fr24Item && fr24Item.json && fr24Item.json.body && fr24Item.json.body.data) {
flightRadar24Data = Array.isArray(fr24Item.json.body.data)
? fr24Item.json.body.data
: [];
}
} catch (e) {
console.log('⚠️ Ошибка извлечения FlightRadar24:', e.message);
}
// ==== УТИЛИТЫ ====
const safeStr = (v) => (v == null ? '' : String(v));
const safeDate = (v) => {
if (!v) return '—';
try {
const d = new Date(v);
return d.toLocaleString('ru-RU', {
timeZone: 'UTC',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return v;
}
};
const formatDuration = (seconds) => {
if (!seconds) return '—';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}ч ${minutes}м`;
};
const formatDistance = (km) => {
if (!km) return '—';
return `${Number(km).toFixed(2)} км`;
};
// ==== ОБЪЕДИНЕНИЕ ДАННЫХ ПО REGISTRATION ====
// Создаём карту для быстрого поиска
const flightsMap = new Map();
// Добавляем данные из FlightAware
flightAwareData.forEach(flight => {
const reg = safeStr(flight.registration).trim();
if (!reg) return;
if (!flightsMap.has(reg)) {
flightsMap.set(reg, {
registration: reg,
flightNumber: safeStr(flight.flight_number),
ident: safeStr(flight.ident),
identIata: safeStr(flight.ident_iata),
aircraftType: safeStr(flight.aircraft_type),
flightAware: flight,
flightRadar24: null
});
} else {
flightsMap.get(reg).flightAware = flight;
}
});
// Добавляем данные из FlightRadar24
flightRadar24Data.forEach(flight => {
const reg = safeStr(flight.reg).trim();
if (!reg) return;
if (!flightsMap.has(reg)) {
flightsMap.set(reg, {
registration: reg,
flightNumber: safeStr(flight.flight),
ident: safeStr(flight.callsign),
identIata: safeStr(flight.flight),
aircraftType: safeStr(flight.type),
flightAware: null,
flightRadar24: flight
});
} else {
flightsMap.get(reg).flightRadar24 = flight;
}
});
// Преобразуем Map в массив
const mergedFlights = Array.from(flightsMap.values());
// ==== ГЕНЕРАЦИЯ HTML ====
const generateFlightCard = (flight) => {
const fa = flight.flightAware;
const fr24 = flight.flightRadar24;
let html = `
<div class="flight-card">
<div class="flight-header">
<h2>Рейс ${flight.flightNumber || flight.ident || 'N/A'}</h2>
<span class="registration">${flight.registration}</span>
</div>
<div class="flight-info">
<div class="info-row">
<span class="label">Тип самолёта:</span>
<span class="value">${flight.aircraftType || '—'}</span>
</div>
<div class="info-row">
<span class="label">Идентификатор:</span>
<span class="value">${flight.ident || '—'} (${flight.identIata || '—'})</span>
</div>
</div>
`;
// Данные из FlightAware
if (fa) {
html += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightaware">FlightAware</span>
</div>
<div class="source-content">
<div class="route-info">
<div class="route-item">
<span class="route-label">Откуда:</span>
<span class="route-value">${safeStr(fa.origin?.name || fa.origin?.code_iata || '—')} (${safeStr(fa.origin?.code_iata || '—')})</span>
</div>
<div class="route-item">
<span class="route-label">Куда:</span>
<span class="route-value">${safeStr(fa.destination?.name || fa.destination?.code_iata || '—')} (${safeStr(fa.destination?.code_iata || '—')})</span>
</div>
</div>
<div class="timeline">
<div class="timeline-item">
<span class="timeline-label">Плановый вылет:</span>
<span class="timeline-value">${safeDate(fa.scheduled_out)}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Фактический вылет:</span>
<span class="timeline-value">${safeDate(fa.actual_out)}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Взлёт:</span>
<span class="timeline-value">${safeDate(fa.actual_off)} ${fa.actual_runway_off ? `(ВПП ${fa.actual_runway_off})` : ''}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Посадка:</span>
<span class="timeline-value">${safeDate(fa.actual_on)} ${fa.actual_runway_on ? `(ВПП ${fa.actual_runway_on})` : ''}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Фактический прилёт:</span>
<span class="timeline-value">${safeDate(fa.actual_in)}</span>
</div>
</div>
<div class="status-info">
<div class="status-item">
<span class="status-label">Статус:</span>
<span class="status-value">${safeStr(fa.status || '—')}</span>
</div>
${fa.departure_delay !== null && fa.departure_delay !== undefined ? `
<div class="status-item">
<span class="status-label">Задержка вылета:</span>
<span class="status-value ${fa.departure_delay < 0 ? 'delay-negative' : 'delay-positive'}">${fa.departure_delay > 0 ? '+' : ''}${Math.floor(fa.departure_delay / 60)} мин</span>
</div>
` : ''}
${fa.arrival_delay !== null && fa.arrival_delay !== undefined ? `
<div class="status-item">
<span class="status-label">Задержка прилёта:</span>
<span class="status-value ${fa.arrival_delay < 0 ? 'delay-negative' : 'delay-positive'}">${fa.arrival_delay > 0 ? '+' : ''}${Math.floor(fa.arrival_delay / 60)} мин</span>
</div>
` : ''}
${fa.gate_origin ? `
<div class="status-item">
<span class="status-label">Гейт вылета:</span>
<span class="status-value">${fa.gate_origin}</span>
</div>
` : ''}
${fa.gate_destination ? `
<div class="status-item">
<span class="status-label">Гейт прилёта:</span>
<span class="status-value">${fa.gate_destination}</span>
</div>
` : ''}
${fa.baggage_claim ? `
<div class="status-item">
<span class="status-label">Выдача багажа:</span>
<span class="status-value">${fa.baggage_claim}</span>
</div>
` : ''}
</div>
</div>
</div>
`;
} else {
html += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightaware">FlightAware</span>
<span class="source-missing">Данные не получены</span>
</div>
</div>
`;
}
// Данные из FlightRadar24
if (fr24) {
html += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightradar24">FlightRadar24</span>
</div>
<div class="source-content">
<div class="route-info">
<div class="route-item">
<span class="route-label">Откуда:</span>
<span class="route-value">${safeStr(fr24.orig_iata || '—')} (${safeStr(fr24.orig_icao || '—')})</span>
</div>
<div class="route-item">
<span class="route-label">Куда:</span>
<span class="route-value">${safeStr(fr24.dest_iata || '—')} (${safeStr(fr24.dest_icao || '—')})</span>
</div>
</div>
<div class="timeline">
<div class="timeline-item">
<span class="timeline-label">Взлёт:</span>
<span class="timeline-value">${safeDate(fr24.datetime_takeoff)} ${fr24.runway_takeoff ? `(ВПП ${fr24.runway_takeoff})` : ''}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Посадка:</span>
<span class="timeline-value">${safeDate(fr24.datetime_landed)} ${fr24.runway_landed ? `(ВПП ${fr24.runway_landed})` : ''}</span>
</div>
</div>
<div class="status-info">
<div class="status-item">
<span class="status-label">Время полёта:</span>
<span class="status-value">${formatDuration(fr24.flight_time)}</span>
</div>
<div class="status-item">
<span class="status-label">Фактическое расстояние:</span>
<span class="status-value">${formatDistance(fr24.actual_distance)}</span>
</div>
<div class="status-item">
<span class="status-label">Кратчайшее расстояние:</span>
<span class="status-value">${formatDistance(fr24.circle_distance)}</span>
</div>
<div class="status-item">
<span class="status-label">Статус полёта:</span>
<span class="status-value">${fr24.flight_ended ? 'Завершён' : 'В процессе'}</span>
</div>
</div>
</div>
</div>
`;
} else {
html += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightradar24">FlightRadar24</span>
<span class="source-missing">Данные не получены</span>
</div>
</div>
`;
}
html += `</div>`;
return html;
};
// ==== ГЕНЕРАЦИЯ ПОЛНОГО HTML ДОКУМЕНТА ====
const generateFullHTML = (flights) => {
const now = new Date();
const reportDate = now.toLocaleString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
let flightsHTML = '';
if (flights.length === 0) {
flightsHTML = '<div class="no-data">Данные о рейсах не найдены</div>';
} else {
flightsHTML = flights.map(flight => generateFlightCard(flight)).join('');
}
return `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Отчёт о рейсах</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header {
border-bottom: 3px solid #2563eb;
padding-bottom: 20px;
margin-bottom: 30px;
}
.header h1 {
color: #1e40af;
font-size: 28px;
margin-bottom: 10px;
}
.header-meta {
color: #666;
font-size: 14px;
}
.sources-info {
display: flex;
gap: 15px;
margin-top: 10px;
flex-wrap: wrap;
}
.source-tag {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.source-tag.available {
background: #d1fae5;
color: #065f46;
}
.source-tag.unavailable {
background: #fee2e2;
color: #991b1b;
}
.flight-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
margin-bottom: 25px;
overflow: hidden;
background: white;
}
.flight-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.flight-header h2 {
font-size: 24px;
margin: 0;
}
.registration {
background: rgba(255,255,255,0.2);
padding: 6px 12px;
border-radius: 4px;
font-weight: 600;
font-size: 14px;
}
.flight-info {
padding: 15px 20px;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
}
.info-row {
display: flex;
margin-bottom: 8px;
}
.info-row .label {
font-weight: 600;
color: #4b5563;
width: 150px;
flex-shrink: 0;
}
.info-row .value {
color: #111827;
}
.source-section {
border-top: 1px solid #e5e7eb;
padding: 20px;
}
.source-section:first-of-type {
border-top: none;
}
.source-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 15px;
}
.source-badge {
display: inline-block;
padding: 6px 14px;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
color: white;
}
.source-badge.source-flightaware {
background: #3b82f6;
}
.source-badge.source-flightradar24 {
background: #10b981;
}
.source-missing {
color: #ef4444;
font-size: 13px;
font-style: italic;
}
.source-content {
margin-left: 0;
}
.route-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin-bottom: 20px;
padding: 15px;
background: #f9fafb;
border-radius: 6px;
}
.route-item {
display: flex;
flex-direction: column;
}
.route-label {
font-size: 12px;
color: #6b7280;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.route-value {
font-size: 16px;
font-weight: 600;
color: #111827;
}
.timeline {
margin-bottom: 20px;
}
.timeline-item {
display: flex;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid #e5e7eb;
}
.timeline-item:last-child {
border-bottom: none;
}
.timeline-label {
font-weight: 500;
color: #4b5563;
width: 180px;
flex-shrink: 0;
}
.timeline-value {
color: #111827;
text-align: right;
}
.status-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
padding: 15px;
background: #f9fafb;
border-radius: 6px;
}
.status-item {
display: flex;
flex-direction: column;
}
.status-label {
font-size: 12px;
color: #6b7280;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-value {
font-size: 14px;
font-weight: 600;
color: #111827;
}
.delay-negative {
color: #10b981;
}
.delay-positive {
color: #ef4444;
}
.no-data {
text-align: center;
padding: 60px 20px;
color: #6b7280;
font-size: 18px;
}
@media print {
body {
background: white;
padding: 0;
}
.container {
box-shadow: none;
padding: 20px;
}
.flight-card {
page-break-inside: avoid;
margin-bottom: 20px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Отчёт о рейсах</h1>
<div class="header-meta">
<div>Дата формирования: ${reportDate}</div>
<div class="sources-info">
<span class="source-tag ${flightAwareData.length > 0 ? 'available' : 'unavailable'}">
FlightAware: ${flightAwareData.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
</span>
<span class="source-tag ${flightRadar24Data.length > 0 ? 'available' : 'unavailable'}">
FlightRadar24: ${flightRadar24Data.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
</span>
</div>
</div>
</div>
<div class="flights-container">
${flightsHTML}
</div>
</div>
</body>
</html>`;
};
// ==== ФОРМИРОВАНИЕ РЕЗУЛЬТАТА ====
const html = generateFullHTML(mergedFlights);
// ==== ПОДГОТОВКА ДАННЫХ ДЛЯ КОНВЕРТАЦИИ В BASE64 PDF ====
// Эти данные будут использованы в следующей HTTP Request ноде
// для конвертации HTML в PDF и получения base64
// Настройки сервиса конвертации (замените на ваши)
const PDF_SERVICE_URL = 'https://api.htmlpdfapi.com/v1/pdf'; // Или другой сервис
const PDF_API_KEY = 'YOUR_API_KEY'; // ⚠️ ЗАМЕНИТЕ на ваш API ключ
// Подготовка запроса для HTTP Request ноды
const pdfRequestData = {
method: 'POST',
url: PDF_SERVICE_URL,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${PDF_API_KEY}`
},
body: JSON.stringify({
html: html,
options: {
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm'
}
},
base64: true // Запрашиваем base64 напрямую
})
};
return [{
json: {
html: html,
flights: mergedFlights,
flights_count: mergedFlights.length,
sources: {
flightaware: {
available: flightAwareData.length > 0,
count: flightAwareData.length
},
flightradar24: {
available: flightRadar24Data.length > 0,
count: flightRadar24Data.length
}
},
generated_at: new Date().toISOString(),
// Данные для конвертации в PDF (используйте в следующей HTTP Request ноде)
pdf_request: pdfRequestData,
pdf_request_method: pdfRequestData.method,
pdf_request_url: pdfRequestData.url,
pdf_request_headers: pdfRequestData.headers,
pdf_request_body: pdfRequestData.body
}
}];

View File

@@ -0,0 +1,110 @@
// ============================================================================
// n8n Code Node: Извлечение Base64 PDF из ответа HTTP Request
// ============================================================================
// Используйте этот код ПОСЛЕ HTTP Request ноды, которая конвертировала HTML в PDF
// ============================================================================
const response = $input.first();
if (!response) {
throw new Error('Ответ от HTTP Request не получен');
}
let base64 = null;
let pdfSize = 0;
// ==== ВАРИАНТ 1: Сервис вернул base64 в JSON ====
if (response.json) {
// htmlpdfapi.com возвращает: { pdf: "base64..." }
if (response.json.pdf) {
base64 = response.json.pdf;
pdfSize = Math.floor(base64.length * 0.75); // Примерный размер
}
// api2pdf.com возвращает: { Pdf: "base64..." }
else if (response.json.Pdf) {
base64 = response.json.Pdf;
pdfSize = Math.floor(base64.length * 0.75);
}
// pdfshift.io возвращает: { pdf: "base64..." }
else if (response.json.pdf) {
base64 = response.json.pdf;
pdfSize = Math.floor(base64.length * 0.75);
}
// Если base64 в другом поле
else if (response.json.base64) {
base64 = response.json.base64;
pdfSize = Math.floor(base64.length * 0.75);
}
// Если base64 в body
else if (response.json.body && typeof response.json.body === 'string') {
base64 = response.json.body;
pdfSize = Math.floor(base64.length * 0.75);
}
}
// ==== ВАРИАНТ 2: Сервис вернул binary PDF ====
if (!base64 && response.binary && response.binary.data) {
const pdfBinary = response.binary.data;
// Конвертируем binary в base64
if (Buffer.isBuffer(pdfBinary)) {
base64 = pdfBinary.toString('base64');
pdfSize = pdfBinary.length;
} else if (typeof pdfBinary === 'string') {
// Если уже base64 строка
base64 = pdfBinary;
pdfSize = Buffer.from(base64, 'base64').length;
} else {
// Пытаемся преобразовать
const buffer = Buffer.from(pdfBinary);
base64 = buffer.toString('base64');
pdfSize = buffer.length;
}
}
// ==== ВАРИАНТ 3: PDF в текстовом формате (base64 строка) ====
if (!base64 && response.json && typeof response.json === 'string') {
base64 = response.json;
pdfSize = Buffer.from(base64, 'base64').length;
}
// ==== ПРОВЕРКА РЕЗУЛЬТАТА ====
if (!base64) {
console.error('❌ Не удалось извлечь base64. Структура ответа:', Object.keys(response));
throw new Error('Не удалось извлечь base64 PDF из ответа. Проверьте формат ответа сервиса.');
}
// Проверяем, что это действительно base64
if (!/^[A-Za-z0-9+/=]+$/.test(base64)) {
throw new Error('Извлечённые данные не являются валидным base64');
}
const pdfSizeMB = (pdfSize / (1024 * 1024)).toFixed(2);
const timestamp = new Date().toISOString().split('T')[0];
const filename = `flights-report-${timestamp}.pdf`;
console.log('✅ Base64 PDF извлечён успешно');
console.log('📊 Размер PDF:', pdfSizeMB, 'MB');
// ==== ВОЗВРАТ РЕЗУЛЬТАТА ====
return [{
json: {
pdf_base64: base64,
pdf_size_bytes: pdfSize,
pdf_size_mb: pdfSizeMB,
filename: filename,
success: true,
generated_at: new Date().toISOString()
}
}];
// ============================================================================
// ИСПОЛЬЗОВАНИЕ РЕЗУЛЬТАТА:
// ============================================================================
// Теперь у вас есть base64 PDF в поле pdf_base64
// Вы можете:
// 1. Сохранить в файл
// 2. Отправить по email
// 3. Загрузить в S3/Nextcloud
// 4. Вернуть в API response
// ============================================================================

View File

@@ -0,0 +1,99 @@
// ============================================================================
// n8n Code Node: HTML → PDF через Browserless (полная версия)
// ============================================================================
// Используйте этот код ПОСЛЕ ноды, которая вернула HTML или html_base64
// ============================================================================
// Получаем HTML из предыдущей ноды
let html = null;
if ($json.html) {
html = $json.html;
} else if ($json.html_base64) {
html = Buffer.from($json.html_base64, 'base64').toString('utf8');
} else if ($json.body?.html) {
html = $json.body.html;
} else if ($binary && $binary.data) {
html = $binary.data.toString('utf8');
} else {
throw new Error('HTML не найден. Проверьте, что предыдущая нода вернула html или html_base64');
}
console.log('📄 HTML получен, длина:', html.length);
// ================== НАСТРОЙКИ BROWSERLESS ==================
const BROWSERLESS_URL = 'http://147.45.146.17:3000';
// ⚠️ ВАЖНО: Если Browserless требует токен, замените на ваш токен
// Если токен не требуется, оставьте пустую строку или удалите Authorization header
const BROWSERLESS_TOKEN = ''; // Замените на ваш токен, если требуется
// Конвертируем HTML в data URL для передачи в Browserless
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
const dataUrl = `data:text/html;base64,${htmlBase64}`;
// Формируем headers
const headers = {
'Content-Type': 'application/json'
};
// Добавляем токен, если он указан
if (BROWSERLESS_TOKEN) {
headers['Authorization'] = `Bearer ${BROWSERLESS_TOKEN}`;
}
// ================== ПОДГОТОВКА ЗАПРОСА ==================
return [{
json: {
// Данные для HTTP Request ноды
method: 'POST',
url: `${BROWSERLESS_URL}/pdf`,
headers: headers,
// Тело запроса - передаём HTML через data URL
body: JSON.stringify({
url: dataUrl,
options: {
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm'
}
}
}),
// Метаданные для отладки
html_length: html.length,
data_url_length: dataUrl.length,
browserless_url: BROWSERLESS_URL
}
}];
// ============================================================================
// ИНСТРУКЦИЯ ПО ИСПОЛЬЗОВАНИЮ:
// ============================================================================
// 1. Замените BROWSERLESS_TOKEN на ваш токен (если требуется)
// 2. Добавьте HTTP Request ноду после этого Code Node
// 3. В HTTP Request ноде настройте:
// - Method: {{ $json.method }}
// - URL: {{ $json.url }}
// - Headers: {{ $json.headers }}
// - Body: {{ $json.body }}
// - Response Format: Binary (Browserless возвращает PDF как binary)
// 4. После HTTP Request добавьте Code Node для конвертации binary в base64:
//
// const pdfBinary = $binary.data;
// const base64 = Buffer.isBuffer(pdfBinary)
// ? pdfBinary.toString('base64')
// : Buffer.from(pdfBinary).toString('base64');
//
// return [{
// json: {
// pdf_base64: base64,
// pdf_size_bytes: Buffer.from(base64, 'base64').length,
// success: true
// }
// }];
// ============================================================================

View File

@@ -0,0 +1,99 @@
// ============================================================================
// n8n Code Node: HTML → PDF через Browserless
// ============================================================================
// Используйте этот код ПОСЛЕ ноды, которая вернула HTML или html_base64
// Подготавливает запрос для HTTP Request ноды к Browserless
// ============================================================================
// Получаем HTML из предыдущей ноды
let html = null;
// Вариант 1: HTML уже есть в json.html
if ($json.html) {
html = $json.html;
}
// Вариант 2: HTML в base64
else if ($json.html_base64) {
html = Buffer.from($json.html_base64, 'base64').toString('utf8');
}
// Вариант 3: HTML в другом поле
else if ($json.body?.html) {
html = $json.body.html;
}
// Вариант 4: Пытаемся получить из binary
else if ($binary && $binary.data) {
html = $binary.data.toString('utf8');
}
else {
throw new Error('HTML не найден. Проверьте, что предыдущая нода вернула html или html_base64');
}
console.log('📄 HTML получен, длина:', html.length);
// ================== НАСТРОЙКИ BROWSERLESS ==================
const BROWSERLESS_URL = 'http://147.45.146.17:3000';
const BROWSERLESS_TOKEN = 'YOUR_TOKEN'; // ⚠️ ЗАМЕНИТЕ на ваш токен Browserless
// ================== ВАРИАНТ 1: Использование data URL ==================
// Browserless может принимать HTML через data URL
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
const dataUrl = `data:text/html;base64,${htmlBase64}`;
return [{
json: {
// Данные для HTTP Request ноды
method: 'POST',
url: `${BROWSERLESS_URL}/pdf`,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${BROWSERLESS_TOKEN}` // Если требуется токен
},
body: JSON.stringify({
url: dataUrl, // Передаём HTML через data URL
options: {
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm'
}
}
}),
// Альтернативный вариант (если Browserless поддерживает прямой HTML)
body_alternative: JSON.stringify({
html: html, // Прямая передача HTML (если поддерживается)
options: {
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm'
}
}
}),
// Метаданные
html_length: html.length,
data_url_length: dataUrl.length
}
}];
// ============================================================================
// ИНСТРУКЦИЯ ПО ИСПОЛЬЗОВАНИЮ:
// ============================================================================
// 1. Замените YOUR_TOKEN на ваш реальный токен Browserless (если требуется)
// 2. Добавьте HTTP Request ноду после этого Code Node
// 3. В HTTP Request ноде настройте:
// - Method: {{ $json.method }}
// - URL: {{ $json.url }}
// - Headers: {{ $json.headers }}
// - Body: {{ $json.body }}
// - Response Format: Binary (или JSON, если Browserless возвращает base64)
// 4. После HTTP Request добавьте Code Node для извлечения base64 из ответа
// (используйте N8N_EXTRACT_BASE64_FROM_RESPONSE.js)
// ============================================================================

View File

@@ -0,0 +1,124 @@
// ============================================================================
// n8n Code Node: HTML → PDF через Browserless (вариант с прямым HTML)
// ============================================================================
// Альтернативный вариант - передача HTML напрямую в body
// ============================================================================
// Получаем HTML из предыдущей ноды
let html = null;
if ($json.html) {
html = $json.html;
} else if ($json.html_base64) {
html = Buffer.from($json.html_base64, 'base64').toString('utf8');
} else if ($json.body?.html) {
html = $json.body.html;
} else if ($binary && $binary.data) {
html = $binary.data.toString('utf8');
} else {
throw new Error('HTML не найден');
}
console.log('📄 HTML получен, длина:', html.length);
// ================== НАСТРОЙКИ ==================
const BROWSERLESS_URL = 'http://147.45.146.17:3000';
const BROWSERLESS_TOKEN = 'YOUR_TOKEN'; // ⚠️ ЗАМЕНИТЕ на ваш токен
// ================== ВАРИАНТ: Использование /screenshot или /pdf ==================
// Browserless может иметь разные эндпоинты
// Вариант A: POST /pdf с HTML в body
const requestA = {
method: 'POST',
url: `${BROWSERLESS_URL}/pdf`,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${BROWSERLESS_TOKEN}`
},
body: JSON.stringify({
html: html,
options: {
format: 'A4',
printBackground: true,
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }
}
})
};
// Вариант B: POST /pdf с data URL
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
const dataUrl = `data:text/html;base64,${htmlBase64}`;
const requestB = {
method: 'POST',
url: `${BROWSERLESS_URL}/pdf`,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${BROWSERLESS_TOKEN}`
},
body: JSON.stringify({
url: dataUrl,
options: {
format: 'A4',
printBackground: true,
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }
}
})
};
// Вариант C: POST /screenshot (если /pdf не работает)
const requestC = {
method: 'POST',
url: `${BROWSERLESS_URL}/screenshot`,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${BROWSERLESS_TOKEN}`
},
body: JSON.stringify({
url: dataUrl,
options: {
type: 'pdf',
format: 'A4',
printBackground: true
}
})
};
return [{
json: {
// Используйте один из вариантов ниже
// Попробуйте сначала вариант A, если не работает - B, затем C
// === ВАРИАНТ A: Прямой HTML ===
method_a: requestA.method,
url_a: requestA.url,
headers_a: requestA.headers,
body_a: requestA.body,
// === ВАРИАНТ B: Data URL ===
method_b: requestB.method,
url_b: requestB.url,
headers_b: requestB.headers,
body_b: requestB.body,
// === ВАРИАНТ C: Screenshot (PDF) ===
method_c: requestC.method,
url_c: requestC.url,
headers_c: requestC.headers,
body_c: requestC.body,
// Метаданные
html_length: html.length,
instruction: 'Попробуйте сначала вариант A в HTTP Request ноде'
}
}];
// ============================================================================
// ОТЛАДКА:
// ============================================================================
// Если получаете ошибку аутентификации:
// 1. Проверьте, нужен ли токен для вашего Browserless
// 2. Если токен не требуется, уберите строку Authorization из headers
// 3. Проверьте документацию Browserless: https://docs.browserless.io
// ============================================================================

View File

@@ -0,0 +1,112 @@
# Полный Workflow: HTML → Base64 PDF
## Структура
```
[HTTP Request: FlightAware]
[HTTP Request: FlightRadar24]
[Code: Process Flights Data] ← Генерирует HTML + подготавливает запрос для PDF
[HTTP Request: Convert to PDF] ← Конвертирует HTML в base64 PDF
[Code: Extract Base64 PDF] ← Извлекает base64 из ответа
[Использование base64 PDF]
```
## Настройка нод
### 1. Code: Process Flights Data
**Код:** Используйте обновлённый `N8N_CODE_PROCESS_FLIGHTS_DATA.js`
**Выходные данные:**
```json
{
"html": "<!DOCTYPE html>...",
"flights": [...],
"pdf_request_method": "POST",
"pdf_request_url": "https://api.htmlpdfapi.com/v1/pdf",
"pdf_request_headers": {...},
"pdf_request_body": "{...}"
}
```
### 2. HTTP Request: Convert to PDF
**Название:** `HTTP Request: Convert to PDF`
**Настройка:**
- **Method:** `{{ $json.pdf_request_method }}`
- **URL:** `{{ $json.pdf_request_url }}`
- **Authentication:** None (или по необходимости)
- **Headers:**
```json
{{ $json.pdf_request_headers }}
```
- **Body:**
```json
{{ $json.pdf_request_body }}
```
- **Response Format:** `JSON`
### 3. Code: Extract Base64 PDF
**Название:** `Code: Extract Base64 PDF`
**Код:** Используйте `N8N_EXTRACT_BASE64_FROM_RESPONSE.js`
**Выходные данные:**
```json
{
"pdf_base64": "JVBERi0xLjQKJeLjz9MK...",
"pdf_size_mb": "0.12",
"filename": "flights-report-2026-01-16.pdf",
"success": true
}
```
## Альтернатива: Использование Convert to File
Если вы хотите использовать ноду **Convert to File** для создания HTML файла, а затем конвертировать его в PDF:
### Вариант A: HTML файл → PDF через сервис
```
[Code: Process Flights Data]
[Convert to File] ← Operation: "html", Put Output File in Field: {{ $json.html }}
[HTTP Request: Convert to PDF] ← Отправьте binary HTML файл в сервис конвертации
[Code: Extract Base64 PDF]
```
### Вариант B: Прямая конвертация HTML → Base64 PDF
Пропустите ноду Convert to File и используйте HTML напрямую:
```
[Code: Process Flights Data]
[HTTP Request: Convert to PDF] ← Используйте {{ $json.html }} в body
[Code: Extract Base64 PDF]
```
## Настройка API ключа
В файле `N8N_CODE_PROCESS_FLIGHTS_DATA.js` найдите строку:
```javascript
const PDF_API_KEY = 'YOUR_API_KEY';
```
Замените `YOUR_API_KEY` на ваш реальный API ключ от сервиса конвертации.
## Популярные сервисы
1. **htmlpdfapi.com** - 100 PDF/месяц бесплатно
2. **pdfshift.io** - 100 PDF/месяц бесплатно
3. **api2pdf.com** - 50 PDF/месяц бесплатно

View File

@@ -0,0 +1,132 @@
// ============================================================================
// n8n Code Node: HTML → PDF через браузер (Puppeteer/Playwright)
// ============================================================================
// Используйте этот код ПОСЛЕ ноды, которая вернула HTML или html_base64
// Подготавливает команду для Execute Command ноды с puppeteer
// ============================================================================
// Получаем HTML из предыдущей ноды
let html = null;
// Вариант 1: HTML уже есть в json.html
if ($json.html) {
html = $json.html;
}
// Вариант 2: HTML в base64
else if ($json.html_base64) {
html = Buffer.from($json.html_base64, 'base64').toString('utf8');
}
// Вариант 3: HTML в другом поле
else if ($json.body?.html) {
html = $json.body.html;
}
// Вариант 4: Пытаемся получить из binary
else if ($binary && $binary.data) {
html = $binary.data.toString('utf8');
}
else {
throw new Error('HTML не найден. Проверьте, что предыдущая нода вернула html или html_base64');
}
console.log('📄 HTML получен, длина:', html.length);
// ================== ВАРИАНТ 1: Execute Command с Puppeteer ==================
// Требует: npm install puppeteer в контейнере n8n
// Команда для Execute Command ноды:
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
const timestamp = Date.now();
const htmlFile = `/tmp/flights-${timestamp}.html`;
const pdfFile = `/tmp/flights-${timestamp}.pdf`;
// Команда для Execute Command ноды:
const command = `node -e "
const puppeteer = require('puppeteer');
const fs = require('fs');
const html = Buffer.from('${htmlBase64}', 'base64').toString('utf8');
(async () => {
const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] });
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
await page.pdf({
path: '${pdfFile}',
format: 'A4',
printBackground: true,
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }
});
await browser.close();
const pdfBuffer = fs.readFileSync('${pdfFile}');
const base64 = pdfBuffer.toString('base64');
console.log(base64);
fs.unlinkSync('${pdfFile}');
})();
"`;
return [{
json: {
// Команда для Execute Command ноды
command: command,
// Или используйте этот вариант (проще):
html_file: htmlFile,
pdf_file: pdfFile,
html_base64: htmlBase64,
// Инструкция
instruction: 'Используйте Execute Command ноду с одной из команд ниже'
}
}];
// ================== ВАРИАНТ 2: HTTP Request к сервису с браузером ==================
// Раскомментируйте, если используете внешний сервис (Gotenberg, Browserless, etc.)
/*
const PDF_SERVICE_URL = 'https://api.gotenberg.dev/forms/chromium/convert/html';
// Или Browserless: 'https://chrome.browserless.io/pdf'
return [{
json: {
method: 'POST',
url: PDF_SERVICE_URL,
headers: {
'Content-Type': 'multipart/form-data'
},
body: {
files: [{
name: 'index.html',
content: html
}],
options: {
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm'
}
}
}
}
}];
*/
// ============================================================================
// ИНСТРУКЦИЯ ПО ИСПОЛЬЗОВАНИЮ:
// ============================================================================
// ВАРИАНТ 1: Execute Command (если puppeteer установлен)
// 1. Установите puppeteer в контейнере n8n:
// docker exec -it <n8n_container> npm install puppeteer
// 2. Добавьте Execute Command ноду после этого Code Node
// 3. В команде используйте: {{ $json.command }}
// 4. После Execute Command добавьте Code Node для извлечения base64 из вывода
//
// ВАРИАНТ 2: HTTP Request к Gotenberg (self-hosted браузер)
// 1. Запустите Gotenberg: docker run -p 3000:3000 gotenberg/gotenberg:7
// 2. Используйте код выше (раскомментируйте)
// 3. Добавьте HTTP Request ноду
//
// ВАРИАНТ 3: HTTP Request к Browserless (cloud сервис)
// 1. Зарегистрируйтесь на browserless.io
// 2. Используйте их API для конвертации
// ============================================================================

View File

@@ -0,0 +1,96 @@
// ============================================================================
// n8n Code Node: Конвертация HTML в Base64 PDF
// ============================================================================
// Используйте этот код после "Code: Process Flights Data"
// для подготовки данных для конвертации в PDF и получения base64
// ============================================================================
// Получаем HTML из предыдущей ноды
const processedData = $('Code: Process Flights Data').first().json;
if (!processedData || !processedData.html) {
throw new Error('HTML не получен из предыдущей ноды');
}
const html = processedData.html;
// ==== ВАРИАНТ 1: HTTP Request к сервису, который возвращает base64 PDF ====
// Используйте этот вариант с HTTP Request нодой после этого Code Node
// Сервисы, которые поддерживают base64:
// - htmlpdfapi.com
// - pdfshift.io
// - api2pdf.com
// - и другие
return [{
json: {
method: 'POST',
url: 'https://api.htmlpdfapi.com/v1/pdf', // Замените на ваш сервис
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_API_KEY' // Замените на ваш API ключ
},
body: JSON.stringify({
html: html,
options: {
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm'
}
},
// Если сервис поддерживает прямое возвращение base64
base64: true
})
}
}];
// ==== ВАРИАНТ 2: Если сервис возвращает binary, конвертируем в base64 ====
// Используйте этот код в Code Node ПОСЛЕ HTTP Request ноды
// (когда получили PDF в binary формате)
/*
const pdfBinary = $binary.data; // Получаем binary данные из HTTP Request
// Конвертируем binary в base64
const base64 = pdfBinary.toString('base64');
return [{
json: {
pdf_base64: base64,
pdf_size_bytes: pdfBinary.length,
pdf_size_mb: (pdfBinary.length / (1024 * 1024)).toFixed(2),
flights_count: processedData.flights_count,
generated_at: processedData.generated_at,
filename: `flights-report-${new Date().toISOString().split('T')[0]}.pdf`
}
}];
*/
// ==== ВАРИАНТ 3: Использование Execute Command с wkhtmltopdf ====
// Если у вас установлен wkhtmltopdf на сервере n8n
// Раскомментируйте и используйте в Execute Command ноде
/*
// Сохраняем HTML во временный файл
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
const timestamp = Date.now();
const htmlFile = `/tmp/flights-${timestamp}.html`;
const pdfFile = `/tmp/flights-${timestamp}.pdf`;
// Команда для Execute Command ноды:
// echo '{{ $json.html_base64 }}' | base64 -d > {{ $json.html_file }} && \
// wkhtmltopdf --page-size A4 --margin-top 20mm --margin-right 15mm --margin-bottom 20mm --margin-left 15mm \
// --print-media-type {{ $json.html_file }} {{ $json.pdf_file }} && \
// cat {{ $json.pdf_file }} | base64 && \
// rm -f {{ $json.html_file }} {{ $json.pdf_file }}
return [{
json: {
html_base64: htmlBase64,
html_file: htmlFile,
pdf_file: pdfFile
}
}];
*/

View File

@@ -0,0 +1,35 @@
# Улучшенное форматирование PDF отчёта
## Что изменено
### Уменьшены отступы:
- **Padding секций:** с `20px``12px 18px`
- **Margin между элементами:** с `20px``12px`
- **Padding карточек:** с `20px``14px 18px`
- **Отступы в timeline:** с `10px``6px`
### Уменьшены размеры шрифтов:
- **Заголовки:** с `24px``20px`
- **Основной текст:** с `14px``13px`
- **Метки:** с `12px``11px`
### Более компактная компоновка:
- **Route info:** уменьшен gap с `15px``12px`
- **Status info:** уменьшен gap с `15px``10px`, minmax с `200px``180px`
- **Timeline:** уменьшена ширина label с `180px``160px`
### Общие улучшения:
- Уменьшен `line-height` с `1.6``1.4` для более плотного текста
- Уменьшены отступы body с `20px``15px`
- Уменьшены отступы container с `30px``20px`
## Результат
**Более компактное отображение** - данные расположены ближе друг к другу
**Меньше разрывов** - плавные переходы между секциями
**Лучшая читаемость** - оптимальный баланс между компактностью и читаемостью
**Экономия места** - больше информации на странице
## Как применить
Скопируйте обновлённый код из `N8N_FLIGHTS_TO_BASE64.js` в вашу Code Node "причесываем данные".

View File

@@ -0,0 +1,72 @@
// ============================================================================
// n8n Code Node: Полный цикл - HTML → Base64 PDF (всё в одном)
// ============================================================================
// Этот код делает всё: получает HTML, отправляет на конвертацию, получает base64
// Требует настройки HTTP Request ноды или внешнего сервиса
// ============================================================================
// Получаем HTML из предыдущей ноды "Code: Process Flights Data"
const processedData = $('Code: Process Flights Data').first().json;
if (!processedData || !processedData.html) {
throw new Error('HTML не получен из предыдущей ноды');
}
const html = processedData.html;
// ==== НАСТРОЙКИ ====
// Замените на ваши параметры
const PDF_SERVICE_URL = 'https://api.htmlpdfapi.com/v1/pdf'; // Или другой сервис
const PDF_API_KEY = 'YOUR_API_KEY'; // Замените на ваш ключ
// ==== ПОДГОТОВКА ЗАПРОСА ДЛЯ HTTP REQUEST ====
// Этот код подготавливает данные для HTTP Request ноды
// После этого Code Node добавьте HTTP Request ноду и используйте эти данные
return [{
json: {
// Данные для HTTP Request ноды
http_method: 'POST',
http_url: PDF_SERVICE_URL,
http_headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${PDF_API_KEY}`
},
http_body: JSON.stringify({
html: html,
options: {
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm'
}
},
base64: true // Запрашиваем base64 напрямую
}),
// Метаданные
html_length: html.length,
flights_count: processedData.flights_count,
generated_at: processedData.generated_at,
// Инструкция для следующей ноды
next_step: 'HTTP Request → Code: Extract Base64 PDF'
}
}];
// ============================================================================
// ИНСТРУКЦИЯ ПО ИСПОЛЬЗОВАНИЮ:
// ============================================================================
// 1. Этот Code Node подготавливает запрос
// 2. Добавьте HTTP Request ноду после этого Code Node
// 3. В HTTP Request ноде используйте:
// - Method: {{ $json.http_method }}
// - URL: {{ $json.http_url }}
// - Headers: {{ $json.http_headers }}
// - Body: {{ $json.http_body }}
// 4. После HTTP Request добавьте Code Node с кодом из N8N_FLIGHTS_PDF_BASE64_FULL.js
// для извлечения base64 из ответа
// ============================================================================

View File

@@ -0,0 +1,81 @@
// ============================================================================
// n8n Code Node: Полная обработка - HTML → Base64 PDF
// ============================================================================
// Этот код обрабатывает ответ от HTTP Request и возвращает base64 PDF
// Используйте ПОСЛЕ HTTP Request ноды, которая конвертирует HTML в PDF
// ============================================================================
// Получаем данные из HTTP Request ноды
const httpResponse = $input.first();
if (!httpResponse) {
throw new Error('Ответ от HTTP Request не получен');
}
// ==== ВАРИАНТ 1: Сервис вернул base64 напрямую в JSON ====
if (httpResponse.json && httpResponse.json.pdf) {
const base64 = httpResponse.json.pdf;
return [{
json: {
pdf_base64: base64,
pdf_size_bytes: Math.floor(base64.length * 0.75), // Примерный размер
pdf_size_mb: (Math.floor(base64.length * 0.75) / (1024 * 1024)).toFixed(2),
success: true,
source: 'json_response'
}
}];
}
// ==== ВАРИАНТ 2: Сервис вернул binary данные ====
if (httpResponse.binary && httpResponse.binary.data) {
const pdfBinary = httpResponse.binary.data;
// Конвертируем binary в base64
// В n8n binary.data может быть Buffer или строка
let base64;
if (Buffer.isBuffer(pdfBinary)) {
base64 = pdfBinary.toString('base64');
} else if (typeof pdfBinary === 'string') {
// Если уже base64 строка
base64 = pdfBinary;
} else {
// Пытаемся преобразовать
base64 = Buffer.from(pdfBinary).toString('base64');
}
const sizeBytes = Buffer.from(base64, 'base64').length;
return [{
json: {
pdf_base64: base64,
pdf_size_bytes: sizeBytes,
pdf_size_mb: (sizeBytes / (1024 * 1024)).toFixed(2),
success: true,
source: 'binary_response'
}
}];
}
// ==== ВАРИАНТ 3: Сервис вернул base64 в поле body или data ====
if (httpResponse.json) {
const body = httpResponse.json.body || httpResponse.json.data || httpResponse.json;
if (body.pdf || body.base64 || body.content) {
const base64 = body.pdf || body.base64 || body.content;
const sizeBytes = Buffer.from(base64, 'base64').length;
return [{
json: {
pdf_base64: base64,
pdf_size_bytes: sizeBytes,
pdf_size_mb: (sizeBytes / (1024 * 1024)).toFixed(2),
success: true,
source: 'body_field'
}
}];
}
}
// ==== ОШИБКА: Не удалось извлечь PDF ====
throw new Error('Не удалось извлечь PDF из ответа. Структура ответа: ' + JSON.stringify(Object.keys(httpResponse), null, 2));

View File

@@ -0,0 +1,65 @@
// ============================================================================
// n8n Code Node: Подготовка данных запроса рейса
// ============================================================================
// Используйте эту ноду ПЕРЕД "причесываем данные"
// Она безопасно получает данные из ноды "запрос рейса" и передаёт их дальше
// ============================================================================
// Получаем данные из ноды "запрос рейса"
let requestData = {
flight_number: null,
departure_date_local: null,
arrival_date_local: null
};
try {
const requestNode = $('запрос рейса');
if (requestNode && requestNode.first()) {
const requestJson = requestNode.first().json;
if (requestJson) {
requestData = {
flight_number: requestJson.flight_number || requestJson.ident || requestJson.flight || null,
departure_date_local: requestJson.departure_date_local || null,
arrival_date_local: requestJson.arrival_date_local || null
};
}
}
} catch (e) {
console.log('⚠️ Не удалось получить данные из ноды "запрос рейса":', e.message);
}
// Получаем данные из входных элементов (fallback)
const inputItems = $input.all();
inputItems.forEach(item => {
if (item.json) {
if (!requestData.flight_number && item.json.flight_number) {
requestData.flight_number = item.json.flight_number;
}
if (!requestData.departure_date_local && item.json.departure_date_local) {
requestData.departure_date_local = item.json.departure_date_local;
}
if (!requestData.arrival_date_local && item.json.arrival_date_local) {
requestData.arrival_date_local = item.json.arrival_date_local;
}
}
});
// Передаём данные дальше вместе с входными данными
const outputItems = inputItems.map(item => ({
...item,
json: {
...item.json,
// Добавляем данные запроса
request_flight_number: requestData.flight_number,
request_departure_date: requestData.departure_date_local,
request_arrival_date: requestData.arrival_date_local
}
}));
return outputItems.length > 0 ? outputItems : [{
json: {
request_flight_number: requestData.flight_number,
request_departure_date: requestData.departure_date_local,
request_arrival_date: requestData.arrival_date_local
}
}];

View File

@@ -0,0 +1,193 @@
# Обработка данных о рейсах в n8n
## Описание
Код для обработки данных о рейсах из двух источников (FlightAware и FlightRadar24), объединения их и генерации красивого HTML для последующей конвертации в PDF.
## Структура входных данных
Workflow должен получать данные в следующем формате:
```json
[
{
"body": {
"flights": [
{
"ident": "CES747",
"registration": "B-1308",
"origin": { "code_iata": "KMG", "name": "Kunming Changshui Int'l" },
"destination": { "code_iata": "PVG", "name": "Shanghai Pudong Int'l" },
...
}
]
}
},
{
"body": {
"data": [
{
"flight": "MU747",
"reg": "B-1308",
"orig_iata": "KMG",
"dest_iata": "PVG",
...
}
]
}
}
]
```
## Установка в n8n
### Шаг 1: Добавить Code Node
1. В вашем workflow после получения данных из FlightAware и FlightRadar24
2. Добавьте ноду **Code** (JavaScript)
3. Назовите её: `Code: Process Flights Data`
### Шаг 2: Вставить код
Скопируйте содержимое файла `N8N_CODE_PROCESS_FLIGHTS_DATA.js` в Code Node.
### Шаг 3: Настройка выхода
Code Node вернёт объект с полями:
- `html` - готовый HTML для конвертации в PDF
- `flights` - массив объединённых данных о рейсах
- `flights_count` - количество рейсов
- `sources` - информация о доступности источников
- `generated_at` - время генерации
## Конвертация HTML в Base64 PDF
### Вариант 1: HTTP Request → Base64 PDF (Рекомендуется)
**Шаг 1:** После Code Node добавьте Code Node с кодом из `N8N_FLIGHTS_PDF_BASE64_COMPLETE.js`
- Этот код подготавливает запрос для HTTP Request
**Шаг 2:** Добавьте HTTP Request ноду:
- Method: `POST`
- URL: `{{ $json.http_url }}` (например, `https://api.htmlpdfapi.com/v1/pdf`)
- Headers: `{{ $json.http_headers }}`
- Body: `{{ $json.http_body }}`
- Response Format: `JSON` или `Binary` (в зависимости от сервиса)
**Шаг 3:** После HTTP Request добавьте Code Node с кодом из `N8N_FLIGHTS_PDF_BASE64_FULL.js`
- Этот код извлекает base64 из ответа сервиса
**Результат:** В выходных данных будет поле `pdf_base64` с готовым PDF в формате base64
### Вариант 2: Прямой запрос к сервису
Используйте код из `N8N_FLIGHTS_HTML_TO_PDF_EXAMPLE.js` для подготовки запроса к сервису конвертации.
**Популярные сервисы:**
- **htmlpdfapi.com** - возвращает base64 в JSON
- **pdfshift.io** - поддерживает base64
- **api2pdf.com** - возвращает base64
- **gotenberg.dev** - бесплатный self-hosted вариант
### Вариант 3: Execute Command с wkhtmltopdf
Если на сервере n8n установлен `wkhtmltopdf`:
1. Сохраните HTML во временный файл
2. Выполните команду:
```bash
wkhtmltopdf --page-size A4 \
--margin-top 20mm --margin-right 15mm \
--margin-bottom 20mm --margin-left 15mm \
--print-media-type input.html output.pdf && \
cat output.pdf | base64
```
3. Получите base64 из вывода команды
### Использование base64 PDF
После получения base64 вы можете:
- Сохранить в файл
- Отправить по email
- Загрузить в S3/Nextcloud
- Вернуть в API response
- Использовать в других workflow
## Особенности обработки
### Объединение данных
Данные объединяются по полю `registration` (номер самолёта):
- FlightAware: `flight.registration`
- FlightRadar24: `flight.reg`
Если для рейса есть данные только из одного источника, они всё равно будут отображены.
### Обработка отсутствующих данных
- Если данные из источника отсутствуют, показывается сообщение "Данные не получены"
- Пустые значения отображаются как "—"
- Даты форматируются в читаемый формат
### Форматирование
HTML включает:
- Красивый дизайн с градиентами и карточками
- Адаптивную вёрстку
- Стили для печати (media queries для print)
- Цветовую индикацию источников данных
- Информацию о задержках (зелёный/красный)
## Пример workflow
```
HTTP Request (FlightAware)
HTTP Request (FlightRadar24)
Code: Process Flights Data ← Вставить код отсюда
HTML/CSS to PDF (или HTTP Request для конвертации)
Save File / Send Email / etc.
```
## Отладка
Если данные не обрабатываются:
1. Проверьте структуру входных данных через `console.log`:
```javascript
console.log('FlightAware:', JSON.stringify(flightAwareData, null, 2));
console.log('FlightRadar24:', JSON.stringify(flightRadar24Data, null, 2));
```
2. Убедитесь, что данные приходят в правильном порядке:
- Первый элемент = FlightAware
- Второй элемент = FlightRadar24
3. Проверьте наличие полей `body.flights` и `body.data`
## Дополнительные возможности
### Кастомизация HTML
Вы можете изменить стили в функции `generateFullHTML()`:
- Цвета
- Шрифты
- Размеры
- Расположение элементов
### Добавление дополнительных полей
В функции `generateFlightCard()` можно добавить отображение дополнительных полей из API.
### Фильтрация рейсов
Перед генерацией HTML можно отфильтровать рейсы:
```javascript
const filteredFlights = mergedFlights.filter(flight => {
// Ваша логика фильтрации
return flight.flightAware || flight.flightRadar24;
});
```

View File

@@ -0,0 +1,236 @@
# Быстрый старт: HTML → Base64 PDF в n8n
## Проблема
У вас есть HTML в формате:
```json
{
"html": "<!DOCTYPE html>..."
}
```
Нужно получить base64 PDF.
## Решение: 3 ноды
### Шаг 1: Code Node - Подготовка запроса
**Название:** `Code: Prepare PDF Request`
**Код:** Скопируйте из `N8N_HTML_TO_BASE64_PDF_SIMPLE.js`
**Важно:**
- Замените `YOUR_API_KEY` на ваш реальный API ключ
- Выберите сервис конвертации (htmlpdfapi.com, pdfshift.io и т.д.)
**Выходные данные:**
```json
{
"method": "POST",
"url": "https://api.htmlpdfapi.com/v1/pdf",
"headers": {...},
"body": "{...}"
}
```
---
### Шаг 2: HTTP Request - Конвертация
**Название:** `HTTP Request: Convert to PDF`
**Настройка:**
- **Method:** `{{ $json.method }}`
- **URL:** `{{ $json.url }}`
- **Authentication:** None (или Basic, если требуется)
- **Headers:**
```json
{{ $json.headers }}
```
- **Body:**
```json
{{ $json.body }}
```
- **Response Format:** `JSON` (или `Binary`, если сервис возвращает binary)
**Что делает:** Отправляет HTML в сервис конвертации и получает PDF
---
### Шаг 3: Code Node - Извлечение Base64
**Название:** `Code: Extract Base64 PDF`
**Код:** Скопируйте из `N8N_EXTRACT_BASE64_FROM_RESPONSE.js`
**Выходные данные:**
```json
{
"pdf_base64": "JVBERi0xLjQKJeLjz9MK...",
"pdf_size_bytes": 123456,
"pdf_size_mb": "0.12",
"filename": "flights-report-2026-01-16.pdf",
"success": true
}
```
---
## Готово!
Теперь у вас есть base64 PDF в поле `pdf_base64`.
## Что дальше?
### Вариант A: Сохранить в файл
Добавьте Code Node:
```javascript
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
const pdfBuffer = Buffer.from(base64, 'base64');
return [{
binary: {
data: pdfBuffer,
fileName: $('Code: Extract Base64 PDF').first().json.filename,
mimeType: 'application/pdf'
}
}];
```
Затем используйте ноду **Write Binary File** или **Save to S3**.
### Вариант B: Вернуть в API
Добавьте Code Node перед Response:
```javascript
const pdfData = $('Code: Extract Base64 PDF').first().json;
return [{
json: {
success: true,
pdf_base64: pdfData.pdf_base64,
pdf_size_mb: pdfData.pdf_size_mb,
filename: pdfData.filename
}
}];
```
### Вариант C: Отправить по Email
Добавьте Code Node:
```javascript
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
const pdfBuffer = Buffer.from(base64, 'base64');
const filename = $('Code: Extract Base64 PDF').first().json.filename;
return [{
json: {
to: 'recipient@example.com',
subject: 'Отчёт о рейсах',
text: 'Во вложении отчёт о рейсах.',
attachments: [{
filename: filename,
content: pdfBuffer,
contentType: 'application/pdf'
}]
}
}];
```
Затем используйте ноду **Email Send**.
---
## Популярные сервисы конвертации
### 1. htmlpdfapi.com (рекомендуется)
- **Бесплатно:** 100 PDF/месяц
- **Платно:** от $9/месяц
- **URL:** https://htmlpdfapi.com
- **Возвращает:** `{ pdf: "base64..." }`
### 2. pdfshift.io
- **Бесплатно:** 100 PDF/месяц
- **Платно:** от $9/месяц
- **URL:** https://pdfshift.io
- **Возвращает:** binary или base64
### 3. api2pdf.com
- **Бесплатно:** 50 PDF/месяц
- **Платно:** от $9/месяц
- **URL:** https://www.api2pdf.com
- **Возвращает:** `{ Pdf: "base64..." }`
### 4. Self-hosted: Gotenberg
- **Бесплатно:** полностью
- **Требует:** Docker
- **URL:** https://gotenberg.dev
- **Возвращает:** binary PDF
---
## Отладка
### Проверка HTML
В Code Node добавьте:
```javascript
console.log('HTML length:', html.length);
console.log('HTML preview:', html.substring(0, 200));
```
### Проверка ответа сервиса
После HTTP Request добавьте Code Node:
```javascript
const response = $input.first();
console.log('Response keys:', Object.keys(response));
console.log('Response json keys:', response.json ? Object.keys(response.json) : 'no json');
console.log('Response binary:', response.binary ? 'yes' : 'no');
```
### Проверка base64
После извлечения base64:
```javascript
const base64 = $json.pdf_base64;
console.log('Base64 length:', base64.length);
console.log('Base64 preview:', base64.substring(0, 50));
```
---
## Частые проблемы
### Проблема: "HTML не найден"
**Решение:** Проверьте, что HTML приходит в поле `html`. Если нет, измените первую строку в `N8N_HTML_TO_BASE64_PDF_SIMPLE.js`:
```javascript
const html = $json.html || $json.body?.html || $json.data?.html || $json;
```
### Проблема: "Не удалось извлечь base64"
**Решение:**
1. Проверьте формат ответа сервиса
2. Добавьте логирование в `N8N_EXTRACT_BASE64_FROM_RESPONSE.js`
3. Убедитесь, что сервис действительно вернул PDF
### Проблема: PDF пустой или повреждён
**Решение:**
1. Проверьте, что HTML валидный
2. Убедитесь, что CSS включён в HTML (inline styles)
3. Проверьте, что сервис поддерживает все используемые CSS свойства
---
## Готовый Workflow
```
[Ваша нода с HTML]
Code: Prepare PDF Request
HTTP Request: Convert to PDF
Code: Extract Base64 PDF
[Использование base64]
```
Всё готово! 🎉

View File

@@ -0,0 +1,103 @@
# Отображение запрошенных рейсов без данных
## Проблема
Когда данных о рейсе нет, нужно показывать, по какому рейсу и запросу информация отсутствует.
## Решение
Код автоматически извлекает информацию о запрошенных рейсах и показывает их даже если данных нет.
## Способы передачи информации о запрошенных рейсах
### Вариант 1: Прямая передача (рекомендуется)
В предыдущей ноде (перед Code Node) добавьте информацию о запрошенных рейсах:
```javascript
// В Code Node перед "причесываем данные"
return [{
json: {
// Ваши данные
...existingData,
// Информация о запрошенных рейсах
requested_flights: ['MU747', 'CES747'], // Массив номеров рейсов
// ИЛИ
flight_number: 'MU747', // Один рейс
// ИЛИ
flight_numbers: ['MU747', 'CES747'] // Альтернативный формат
}
}];
```
### Вариант 2: Автоматическое извлечение
Код автоматически пытается извлечь информацию о рейсах из:
- URL запросов (параметры `ident`, `flight_number`, `flight`, `callsign`)
- Query параметров
- Body запросов
- Прямых полей в JSON
## Что отображается
Если рейс был запрошен, но данных нет, показывается карточка:
```
┌─ Рейс MU747 ───────────────┐
│ Запрошен │
│ │
│ Запрошенный рейс: MU747 │
│ │
│ [FlightAware] │
│ ✗ Данные не получены │
│ │
│ [FlightRadar24] │
│ ✗ Данные не получены │
└────────────────────────────┘
```
## Пример использования
### В предыдущей ноде (HTTP Request или Code Node):
```javascript
// После запросов к FlightAware и FlightRadar24
return [{
json: {
data: [
{ body: { flights: [...] } }, // FlightAware ответ
{ body: { data: [...] } } // FlightRadar24 ответ
],
// Добавляем информацию о запрошенных рейсах
requested_flights: ['MU747', 'CES747']
}
}];
```
### Или в отдельной ноде перед обработкой:
```javascript
// Code Node: Prepare Request Info
const flightNumbers = ['MU747', 'CES747']; // Из вашего запроса
return [{
json: {
requested_flights: flightNumbers,
// Другие данные...
}
}];
```
## Преимущества
**Прозрачность** - видно, какие рейсы запрашивались
**Отладка** - легко понять, почему данных нет
**Информативность** - пользователь видит, что запрос был выполнен
**Автоматика** - код пытается извлечь информацию автоматически
## Если данные всё равно не показываются
1. Проверьте, что передаёте `requested_flights` в предыдущей ноде
2. Убедитесь, что формат правильный: массив строк или объект с полем `flight_number`
3. Проверьте логи в Code Node - там будут сообщения о найденных запрошенных рейсах

View File

@@ -0,0 +1,370 @@
// ============================================================================
// n8n Code Node: Отчёт о рейсах (HTML → Binary + Base64 PDF)
// ============================================================================
// Упрощённая версия с возвратом binary HTML и подготовкой для PDF конвертации
// ============================================================================
const inputItems = $input.all();
// ================== FALLBACK ==================
if (!inputItems || inputItems.length === 0) {
const html = '<!DOCTYPE html><html><body><h1>Ошибка: данные не получены</h1></body></html>';
return [{
binary: {
data: Buffer.from(html, 'utf8'),
mimeType: 'text/html',
fileName: 'flights-report.html'
},
json: {
html: html,
flights_count: 0,
error: 'Нет входных данных'
}
}];
}
// ================== ИЗВЛЕЧЕНИЕ ДАННЫХ ==================
let flightAwareData = [];
let flightRadar24Data = [];
try {
const fa = inputItems[0]?.json?.body?.flights;
if (Array.isArray(fa)) flightAwareData = fa;
} catch (e) {
console.log('⚠️ Ошибка извлечения FlightAware:', e.message);
}
try {
const fr = inputItems[1]?.json?.body?.data;
if (Array.isArray(fr)) flightRadar24Data = fr;
} catch (e) {
console.log('⚠️ Ошибка извлечения FlightRadar24:', e.message);
}
// ================== УТИЛИТЫ ==================
const safeStr = v => (v == null ? '' : String(v));
const safeDate = v => {
if (!v) return '—';
try {
const d = new Date(v);
return isNaN(d.getTime()) ? '—' : d.toLocaleString('ru-RU', {
timeZone: 'UTC',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return '—';
}
};
const formatDuration = s => !s ? '—' : `${Math.floor(s / 3600)}ч ${Math.floor((s % 3600) / 60)}м`;
const formatDistance = km => !km ? '—' : `${Number(km).toFixed(2)} км`;
// ================== MERGE ПО REGISTRATION ==================
const flightsMap = new Map();
flightAwareData.forEach(f => {
const reg = safeStr(f.registration).trim();
if (!reg) return;
if (!flightsMap.has(reg)) {
flightsMap.set(reg, {
registration: reg,
flightNumber: safeStr(f.flight_number),
ident: safeStr(f.ident),
identIata: safeStr(f.ident_iata),
aircraftType: safeStr(f.aircraft_type),
fa: f,
fr: null
});
} else {
flightsMap.get(reg).fa = f;
}
});
flightRadar24Data.forEach(f => {
const reg = safeStr(f.reg).trim();
if (!reg) return;
if (!flightsMap.has(reg)) {
flightsMap.set(reg, {
registration: reg,
flightNumber: safeStr(f.flight),
ident: safeStr(f.callsign),
identIata: safeStr(f.flight),
aircraftType: safeStr(f.type),
fa: null,
fr: f
});
} else {
flightsMap.get(reg).fr = f;
}
});
const flights = Array.from(flightsMap.values());
// ================== HTML GENERATION ==================
const generateFlightCard = f => {
const fa = f.fa;
const fr = f.fr;
let card = `
<div class="flight-card">
<div class="flight-header">
<h2>Рейс ${f.flightNumber || f.ident || '—'}</h2>
<span class="registration">${f.registration}</span>
</div>
<div class="flight-info">
<div class="info-row">
<span class="label">Тип самолёта:</span>
<span class="value">${f.aircraftType || '—'}</span>
</div>
<div class="info-row">
<span class="label">Идентификатор:</span>
<span class="value">${f.ident || '—'} (${f.identIata || '—'})</span>
</div>
</div>`;
if (fa) {
card += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightaware">FlightAware</span>
</div>
<div class="source-content">
<div class="route-info">
<div class="route-item">
<span class="route-label">Откуда:</span>
<span class="route-value">${safeStr(fa.origin?.name || fa.origin?.code_iata || '—')} (${safeStr(fa.origin?.code_iata || '—')})</span>
</div>
<div class="route-item">
<span class="route-label">Куда:</span>
<span class="route-value">${safeStr(fa.destination?.name || fa.destination?.code_iata || '—')} (${safeStr(fa.destination?.code_iata || '—')})</span>
</div>
</div>
<div class="timeline">
<div class="timeline-item">
<span class="timeline-label">Вылет:</span>
<span class="timeline-value">${safeDate(fa.actual_out)}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Прилёт:</span>
<span class="timeline-value">${safeDate(fa.actual_in)}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Статус:</span>
<span class="timeline-value">${safeStr(fa.status || '—')}</span>
</div>
</div>
</div>
</div>`;
} else {
card += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightaware">FlightAware</span>
<span class="source-missing">Данные не получены</span>
</div>
</div>`;
}
if (fr) {
card += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightradar24">FlightRadar24</span>
</div>
<div class="source-content">
<div class="route-info">
<div class="route-item">
<span class="route-label">Откуда:</span>
<span class="route-value">${safeStr(fr.orig_iata || '—')} (${safeStr(fr.orig_icao || '—')})</span>
</div>
<div class="route-item">
<span class="route-label">Куда:</span>
<span class="route-value">${safeStr(fr.dest_iata || '—')} (${safeStr(fr.dest_icao || '—')})</span>
</div>
</div>
<div class="status-info">
<div class="status-item">
<span class="status-label">Время полёта:</span>
<span class="status-value">${formatDuration(fr.flight_time)}</span>
</div>
<div class="status-item">
<span class="status-label">Расстояние:</span>
<span class="status-value">${formatDistance(fr.actual_distance)}</span>
</div>
</div>
</div>
</div>`;
} else {
card += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightradar24">FlightRadar24</span>
<span class="source-missing">Данные не получены</span>
</div>
</div>`;
}
card += `</div>`;
return card;
};
const now = new Date();
const reportDate = now.toLocaleString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
const html = `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Отчёт о рейсах</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; line-height: 1.6; color: #333; background: #f5f5f5; padding: 20px; }
.container { max-width: 1200px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.header { border-bottom: 3px solid #2563eb; padding-bottom: 20px; margin-bottom: 30px; }
.header h1 { color: #1e40af; font-size: 28px; margin-bottom: 10px; }
.header-meta { color: #666; font-size: 14px; }
.sources-info { display: flex; gap: 15px; margin-top: 10px; flex-wrap: wrap; }
.source-tag { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 500; }
.source-tag.available { background: #d1fae5; color: #065f46; }
.source-tag.unavailable { background: #fee2e2; color: #991b1b; }
.flight-card { border: 1px solid #e5e7eb; border-radius: 8px; margin-bottom: 25px; overflow: hidden; background: white; }
.flight-header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; display: flex; justify-content: space-between; align-items: center; }
.flight-header h2 { font-size: 24px; margin: 0; }
.registration { background: rgba(255,255,255,0.2); padding: 6px 12px; border-radius: 4px; font-weight: 600; font-size: 14px; }
.flight-info { padding: 15px 20px; background: #f9fafb; border-bottom: 1px solid #e5e7eb; }
.info-row { display: flex; margin-bottom: 8px; }
.info-row .label { font-weight: 600; color: #4b5563; width: 150px; flex-shrink: 0; }
.info-row .value { color: #111827; }
.source-section { border-top: 1px solid #e5e7eb; padding: 20px; }
.source-section:first-of-type { border-top: none; }
.source-header { display: flex; align-items: center; gap: 10px; margin-bottom: 15px; }
.source-badge { display: inline-block; padding: 6px 14px; border-radius: 6px; font-size: 13px; font-weight: 600; color: white; }
.source-badge.source-flightaware { background: #3b82f6; }
.source-badge.source-flightradar24 { background: #10b981; }
.source-missing { color: #ef4444; font-size: 13px; font-style: italic; }
.source-content { margin-left: 0; }
.route-info { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 20px; padding: 15px; background: #f9fafb; border-radius: 6px; }
.route-item { display: flex; flex-direction: column; }
.route-label { font-size: 12px; color: #6b7280; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
.route-value { font-size: 16px; font-weight: 600; color: #111827; }
.timeline { margin-bottom: 20px; }
.timeline-item { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e5e7eb; }
.timeline-item:last-child { border-bottom: none; }
.timeline-label { font-weight: 500; color: #4b5563; width: 180px; flex-shrink: 0; }
.timeline-value { color: #111827; text-align: right; }
.status-info { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; padding: 15px; background: #f9fafb; border-radius: 6px; }
.status-item { display: flex; flex-direction: column; }
.status-label { font-size: 12px; color: #6b7280; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
.status-value { font-size: 14px; font-weight: 600; color: #111827; }
.no-data { text-align: center; padding: 60px 20px; color: #6b7280; font-size: 18px; }
@media print { body { background: white; padding: 0; } .container { box-shadow: none; padding: 20px; } .flight-card { page-break-inside: avoid; margin-bottom: 20px; } }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Отчёт о рейсах</h1>
<div class="header-meta">
<div>Дата формирования: ${reportDate}</div>
<div class="sources-info">
<span class="source-tag ${flightAwareData.length > 0 ? 'available' : 'unavailable'}">
FlightAware: ${flightAwareData.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
</span>
<span class="source-tag ${flightRadar24Data.length > 0 ? 'available' : 'unavailable'}">
FlightRadar24: ${flightRadar24Data.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
</span>
</div>
</div>
</div>
<div class="flights-container">
${flights.length ? flights.map(generateFlightCard).join('') : '<div class="no-data">Данные о рейсах не найдены</div>'}
</div>
</div>
</body>
</html>`;
// ================== ПОДГОТОВКА ДАННЫХ ДЛЯ PDF КОНВЕРТАЦИИ ==================
// Настройки сервиса (замените на ваши)
const PDF_SERVICE_URL = 'https://api.htmlpdfapi.com/v1/pdf';
const PDF_API_KEY = 'YOUR_API_KEY'; // ⚠️ ЗАМЕНИТЕ на ваш API ключ
// ================== RETURN ==================
return [{
// Binary HTML файл (для использования в Convert to File ноде или сохранения)
binary: {
data: Buffer.from(html, 'utf8'),
mimeType: 'text/html',
fileName: `flights-report-${now.toISOString().split('T')[0]}.html`
},
// JSON данные
json: {
// HTML строка (для конвертации в PDF через HTTP Request)
html: html,
// Метаданные
flights_count: flights.length,
generated_at: now.toISOString(),
sources: {
flightaware: { available: flightAwareData.length > 0, count: flightAwareData.length },
flightradar24: { available: flightRadar24Data.length > 0, count: flightRadar24Data.length }
},
// Данные для конвертации в base64 PDF (используйте в следующей HTTP Request ноде)
pdf_request: {
method: 'POST',
url: PDF_SERVICE_URL,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${PDF_API_KEY}`
},
body: JSON.stringify({
html: html,
options: {
format: 'A4',
printBackground: true,
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }
},
base64: true
})
},
// Удобные поля для HTTP Request ноды
pdf_request_method: 'POST',
pdf_request_url: PDF_SERVICE_URL,
pdf_request_headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${PDF_API_KEY}`
},
pdf_request_body: JSON.stringify({
html: html,
options: {
format: 'A4',
printBackground: true,
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }
},
base64: true
})
}
}];
// ============================================================================
// ИСПОЛЬЗОВАНИЕ:
// ============================================================================
// 1. Binary HTML можно использовать в ноде "Convert to File" или сохранить
// 2. JSON.html можно использовать для конвертации в PDF через HTTP Request
// 3. JSON.pdf_request_* поля готовы для использования в HTTP Request ноде
// 4. После HTTP Request используйте N8N_EXTRACT_BASE64_FROM_RESPONSE.js
// для извлечения base64 PDF из ответа
// ============================================================================

View File

@@ -0,0 +1,630 @@
// ============================================================================
// n8n Code Node: Обработка данных о рейсах → Base64 HTML
// ============================================================================
// Вход: [{ data: [{ body: { flights: [...] }}, { body: { data: [...] }}] }]
// Выход: base64 HTML
// ============================================================================
const inputItems = $input.all();
// ================== FALLBACK ==================
if (!inputItems || inputItems.length === 0) {
const html = '<!DOCTYPE html><html><body><h1>Ошибка: данные не получены</h1></body></html>';
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
return [{
json: {
html_base64: htmlBase64,
html: html,
flights_count: 0,
error: 'Нет входных данных'
}
}];
}
// ================== ИЗВЛЕЧЕНИЕ ДАННЫХ ==================
// Новая структура: [{ data: [{ body: { flights: [...] }}, { error: {...} }, { flight_number, ... }] }]
let flightAwareData = [];
let flightRadar24Data = [];
let requestData = null; // Данные из ноды "запрос рейса"
let flightRadar24Error = null; // Ошибка от FlightRadar24
try {
const firstItem = inputItems[0];
if (firstItem && firstItem.json && firstItem.json.data && Array.isArray(firstItem.json.data)) {
// Первый элемент массива data - FlightAware
if (firstItem.json.data[0] && firstItem.json.data[0].body) {
if (firstItem.json.data[0].body.flights) {
flightAwareData = Array.isArray(firstItem.json.data[0].body.flights)
? firstItem.json.data[0].body.flights
: [];
}
}
// Второй элемент массива data - FlightRadar24 (может быть ошибка)
if (firstItem.json.data[1]) {
// Проверяем, есть ли ошибка
if (firstItem.json.data[1].error) {
flightRadar24Error = firstItem.json.data[1].error;
console.log('⚠️ Ошибка FlightRadar24:', flightRadar24Error.message);
flightRadar24Data = [];
} else if (firstItem.json.data[1].body && firstItem.json.data[1].body.data) {
flightRadar24Data = Array.isArray(firstItem.json.data[1].body.data)
? firstItem.json.data[1].body.data
: [];
}
}
// Третий элемент массива data - данные из ноды "запрос рейса"
if (firstItem.json.data[2] && firstItem.json.data[2].flight_number) {
requestData = {
flight_number: firstItem.json.data[2].flight_number,
departure_date_local: firstItem.json.data[2].departure_date_local || null,
arrival_date_local: firstItem.json.data[2].arrival_date_local || null
};
console.log('✅ Данные запроса получены:', requestData);
}
}
} catch (e) {
console.log('⚠️ Ошибка извлечения данных:', e.message);
}
// ================== УТИЛИТЫ ==================
const safeStr = v => (v == null ? '' : String(v));
const safeDate = v => {
if (!v) return '—';
try {
const d = new Date(v);
return isNaN(d.getTime()) ? '—' : d.toLocaleString('ru-RU', {
timeZone: 'UTC',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return '—';
}
};
const formatDuration = s => !s ? '—' : `${Math.floor(s / 3600)}ч ${Math.floor((s % 3600) / 60)}м`;
const formatDistance = km => !km ? '—' : `${Number(km).toFixed(2)} км`;
// ================== MERGE ПО REGISTRATION ==================
const flightsMap = new Map();
// Добавляем данные из FlightAware
flightAwareData.forEach(f => {
const reg = safeStr(f.registration).trim();
if (!reg) return;
if (!flightsMap.has(reg)) {
flightsMap.set(reg, {
registration: reg,
flightNumber: safeStr(f.flight_number),
ident: safeStr(f.ident),
identIata: safeStr(f.ident_iata),
aircraftType: safeStr(f.aircraft_type),
fa: f,
fr: null
});
} else {
flightsMap.get(reg).fa = f;
}
});
// Добавляем данные из FlightRadar24
flightRadar24Data.forEach(f => {
const reg = safeStr(f.reg).trim();
if (!reg) return;
if (!flightsMap.has(reg)) {
flightsMap.set(reg, {
registration: reg,
flightNumber: safeStr(f.flight),
ident: safeStr(f.callsign),
identIata: safeStr(f.flight),
aircraftType: safeStr(f.type),
fa: null,
fr: f
});
} else {
flightsMap.get(reg).fr = f;
}
});
// ================== ДОБАВЛЕНИЕ ЗАПРОШЕННЫХ РЕЙСОВ БЕЗ ДАННЫХ ==================
// Если есть информация о запрошенных рейсах, но нет данных - добавляем их
// Пытаемся извлечь из предыдущих нод (HTTP Request) или получить из входных данных
const allInputItems = $input.all();
const firstItemForRequest = inputItems[0]; // Используем уже определённую переменную из блока выше
// Ищем информацию о запрошенных рейсах
let requestedFlightNumbers = new Set();
// ВАРИАНТ 1: Получение данных из ноды "запрос рейса"
// Используем данные, извлечённые выше из data[2]
let requestFlightNumber = null;
let requestDepartureDate = null;
let requestArrivalDate = null;
if (requestData) {
requestFlightNumber = requestData.flight_number;
requestDepartureDate = requestData.departure_date_local;
requestArrivalDate = requestData.arrival_date_local;
if (requestFlightNumber) {
requestedFlightNumbers.add(String(requestFlightNumber));
}
}
// Дополнительно ищем в других местах (fallback)
allInputItems.forEach(item => {
if (item.json) {
// Прямые поля из ноды "запрос рейса"
if (item.json.flight_number && (item.json.departure_date_local || item.json.arrival_date_local)) {
if (!requestFlightNumber) {
requestFlightNumber = item.json.flight_number || item.json.ident || item.json.flight;
requestDepartureDate = item.json.departure_date_local || null;
requestArrivalDate = item.json.arrival_date_local || null;
if (requestFlightNumber) {
requestedFlightNumbers.add(String(requestFlightNumber));
}
}
}
// Данные, переданные из предыдущей ноды
if (item.json.request_flight_number) {
if (!requestFlightNumber) {
requestFlightNumber = item.json.request_flight_number;
requestDepartureDate = item.json.request_departure_date || null;
requestArrivalDate = item.json.request_arrival_date || null;
if (requestFlightNumber) {
requestedFlightNumbers.add(String(requestFlightNumber));
}
}
}
}
});
// ВАРИАНТ 2: Прямая передача из предыдущей ноды
if (firstItemForRequest && firstItemForRequest.json) {
// Массив запрошенных рейсов
if (firstItemForRequest.json.requested_flights && Array.isArray(firstItemForRequest.json.requested_flights)) {
firstItemForRequest.json.requested_flights.forEach(flight => {
const flightNum = typeof flight === 'string' ? flight : (flight.flight_number || flight.ident || flight);
if (flightNum) {
requestedFlightNumbers.add(flightNum);
}
});
}
// Один рейс
if (firstItemForRequest.json.flight_number || firstItemForRequest.json.ident || firstItemForRequest.json.flight) {
const flightNum = firstItemForRequest.json.flight_number || firstItemForRequest.json.ident || firstItemForRequest.json.flight;
requestedFlightNumbers.add(flightNum);
}
// Массив flight_numbers
if (firstItemForRequest.json.flight_numbers && Array.isArray(firstItemForRequest.json.flight_numbers)) {
firstItemForRequest.json.flight_numbers.forEach(flightNum => {
if (flightNum) requestedFlightNumbers.add(String(flightNum));
});
}
}
// ВАРИАНТ 3: Извлечение из всех входных элементов
allInputItems.forEach(item => {
if (item.json) {
if (item.json.flight_number) {
requestedFlightNumbers.add(String(item.json.flight_number));
}
if (item.json.ident) {
requestedFlightNumbers.add(String(item.json.ident));
}
if (item.json.flight) {
requestedFlightNumbers.add(String(item.json.flight));
}
}
});
// ВАРИАНТ 2: Извлечение из URL и параметров запросов
allInputItems.forEach(item => {
// Из URL запроса
if (item.json && item.json.url) {
const url = item.json.url;
const flightMatch = url.match(/(?:ident|flight_number|flight|callsign)=([^&]+)/i);
if (flightMatch) {
requestedFlightNumbers.add(flightMatch[1]);
}
}
// Из query параметров
if (item.json && item.json.query) {
const query = item.json.query;
const flightNum = query.ident || query.flight_number || query.flight || query.callsign;
if (flightNum) {
requestedFlightNumbers.add(flightNum);
}
}
// Из body запроса
if (item.json && item.json.body) {
const body = item.json.body;
const flightNum = body.ident || body.flight_number || body.flight || body.callsign;
if (flightNum) {
requestedFlightNumbers.add(flightNum);
}
}
// Прямо из json
if (item.json) {
const flightNum = item.json.ident || item.json.flight_number || item.json.flight || item.json.callsign;
if (flightNum) {
requestedFlightNumbers.add(flightNum);
}
}
});
// Добавляем запрошенные рейсы, для которых нет данных
requestedFlightNumbers.forEach(flightNum => {
// Проверяем, есть ли уже этот рейс в flightsMap
let found = false;
flightsMap.forEach((flight, reg) => {
if (flight.flightNumber === flightNum || flight.ident === flightNum || flight.identIata === flightNum) {
found = true;
}
});
// Если не найден - добавляем как запрошенный без данных
if (!found) {
flightsMap.set(`REQUESTED-${flightNum}`, {
registration: '—',
flightNumber: flightNum,
ident: flightNum,
identIata: flightNum,
aircraftType: '—',
fa: null,
fr: null,
isRequested: true // Флаг, что это запрошенный рейс без данных
});
}
});
const flights = Array.from(flightsMap.values());
// ================== HTML GENERATION ==================
// Делаем flightRadar24Error доступным в функции generateFlightCard
const generateFlightCard = (f, fr24ErrorParam = null) => {
const fa = f.fa;
const fr = f.fr;
const fr24Error = fr24ErrorParam; // Локальная переменная для использования в функции
// Если это запрошенный рейс без данных
if (f.isRequested && !fa && !fr) {
// Используем данные, полученные ранее из ноды "запрос рейса"
let requestInfo = '';
// Проверяем, соответствует ли этот рейс запрошенному
const matchesRequest = requestFlightNumber && (
String(f.flightNumber) === String(requestFlightNumber) ||
String(f.ident) === String(requestFlightNumber)
);
if (matchesRequest) {
if (requestDepartureDate) {
requestInfo += `<div class="info-row"><span class="label">Дата вылета (запрос):</span><span class="value">${requestDepartureDate}</span></div>`;
}
if (requestArrivalDate) {
requestInfo += `<div class="info-row"><span class="label">Дата прилёта (запрос):</span><span class="value">${requestArrivalDate}</span></div>`;
}
}
return `
<div class="flight-card">
<div class="flight-header">
<h2>Рейс ${f.flightNumber || f.ident || '—'}</h2>
<span class="registration">Запрошен</span>
</div>
<div class="flight-info">
<div class="info-row">
<span class="label">Запрошенный рейс:</span>
<span class="value">${f.flightNumber || f.ident || '—'}</span>
</div>
${requestInfo}
</div>
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightaware">FlightAware</span>
<span class="source-missing">Данные не получены</span>
</div>
<div class="source-content">
<div style="padding: 10px; color: #666; font-size: 13px;">
По запросу рейса <strong>${f.flightNumber || f.ident || '—'}</strong>${requestDepartureDate ? ` на ${requestDepartureDate}` : ''} данные не найдены.
</div>
</div>
</div>
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightradar24">FlightRadar24</span>
<span class="source-missing">Данные не получены</span>
</div>
<div class="source-content">
<div style="padding: 10px; color: #666; font-size: 13px;">
По запросу рейса <strong>${f.flightNumber || f.ident || '—'}</strong>${requestDepartureDate ? ` на ${requestDepartureDate}` : ''} данные не найдены.
</div>
</div>
</div>
</div>`;
}
let card = `
<div class="flight-card">
<div class="flight-header">
<h2>Рейс ${f.flightNumber || f.ident || '—'}</h2>
<span class="registration">${f.registration || '—'}</span>
</div>
<div class="flight-info">
<div class="info-row">
<span class="label">Тип самолёта:</span>
<span class="value">${f.aircraftType || '—'}</span>
</div>
<div class="info-row">
<span class="label">Идентификатор:</span>
<span class="value">${f.ident || '—'} (${f.identIata || '—'})</span>
</div>
</div>`;
// Данные из FlightAware
if (fa) {
card += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightaware">FlightAware</span>
</div>
<div class="source-content">
<div class="route-info">
<div class="route-item">
<span class="route-label">Откуда:</span>
<span class="route-value">${safeStr(fa.origin?.name || fa.origin?.code_iata || '—')} (${safeStr(fa.origin?.code_iata || '—')})</span>
</div>
<div class="route-item">
<span class="route-label">Куда:</span>
<span class="route-value">${safeStr(fa.destination?.name || fa.destination?.code_iata || '—')} (${safeStr(fa.destination?.code_iata || '—')})</span>
</div>
</div>
<div class="timeline">
<div class="timeline-item">
<span class="timeline-label">Плановый вылет:</span>
<span class="timeline-value">${safeDate(fa.scheduled_out)}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Фактический вылет:</span>
<span class="timeline-value">${safeDate(fa.actual_out)}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Взлёт:</span>
<span class="timeline-value">${safeDate(fa.actual_off)} ${fa.actual_runway_off ? `(ВПП ${fa.actual_runway_off})` : ''}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Посадка:</span>
<span class="timeline-value">${safeDate(fa.actual_on)} ${fa.actual_runway_on ? `(ВПП ${fa.actual_runway_on})` : ''}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Фактический прилёт:</span>
<span class="timeline-value">${safeDate(fa.actual_in)}</span>
</div>
</div>
<div class="status-info">
<div class="status-item">
<span class="status-label">Статус:</span>
<span class="status-value">${safeStr(fa.status || '—')}</span>
</div>
${fa.departure_delay !== null && fa.departure_delay !== undefined ? `
<div class="status-item">
<span class="status-label">Задержка вылета:</span>
<span class="status-value ${fa.departure_delay < 0 ? 'delay-negative' : 'delay-positive'}">${fa.departure_delay > 0 ? '+' : ''}${Math.floor(fa.departure_delay / 60)} мин</span>
</div>
` : ''}
${fa.arrival_delay !== null && fa.arrival_delay !== undefined ? `
<div class="status-item">
<span class="status-label">Задержка прилёта:</span>
<span class="status-value ${fa.arrival_delay < 0 ? 'delay-negative' : 'delay-positive'}">${fa.arrival_delay > 0 ? '+' : ''}${Math.floor(fa.arrival_delay / 60)} мин</span>
</div>
` : ''}
${fa.gate_origin ? `
<div class="status-item">
<span class="status-label">Гейт вылета:</span>
<span class="status-value">${fa.gate_origin}</span>
</div>
` : ''}
${fa.gate_destination ? `
<div class="status-item">
<span class="status-label">Гейт прилёта:</span>
<span class="status-value">${fa.gate_destination}</span>
</div>
` : ''}
${fa.baggage_claim ? `
<div class="status-item">
<span class="status-label">Выдача багажа:</span>
<span class="status-value">${fa.baggage_claim}</span>
</div>
` : ''}
</div>
</div>
</div>`;
} else {
card += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightaware">FlightAware</span>
<span class="source-missing">Данные не получены</span>
</div>
</div>`;
}
// Данные из FlightRadar24
if (fr) {
card += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightradar24">FlightRadar24</span>
</div>
<div class="source-content">
<div class="route-info">
<div class="route-item">
<span class="route-label">Откуда:</span>
<span class="route-value">${safeStr(fr.orig_iata || '—')} (${safeStr(fr.orig_icao || '—')})</span>
</div>
<div class="route-item">
<span class="route-label">Куда:</span>
<span class="route-value">${safeStr(fr.dest_iata || '—')} (${safeStr(fr.dest_icao || '—')})</span>
</div>
</div>
<div class="timeline">
<div class="timeline-item">
<span class="timeline-label">Взлёт:</span>
<span class="timeline-value">${safeDate(fr.datetime_takeoff)} ${fr.runway_takeoff ? `(ВПП ${fr.runway_takeoff})` : ''}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Посадка:</span>
<span class="timeline-value">${safeDate(fr.datetime_landed)} ${fr.runway_landed ? `(ВПП ${fr.runway_landed})` : ''}</span>
</div>
</div>
<div class="status-info">
<div class="status-item">
<span class="status-label">Время полёта:</span>
<span class="status-value">${formatDuration(fr.flight_time)}</span>
</div>
<div class="status-item">
<span class="status-label">Фактическое расстояние:</span>
<span class="status-value">${formatDistance(fr.actual_distance)}</span>
</div>
<div class="status-item">
<span class="status-label">Кратчайшее расстояние:</span>
<span class="status-value">${formatDistance(fr.circle_distance)}</span>
</div>
<div class="status-item">
<span class="status-label">Статус полёта:</span>
<span class="status-value">${fr.flight_ended ? 'Завершён' : 'В процессе'}</span>
</div>
</div>
</div>
</div>`;
} else {
card += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightradar24">FlightRadar24</span>
<span class="source-missing">Данные не получены</span>
</div>
</div>`;
}
card += `</div>`;
return card;
};
// Генерация полного HTML
const now = new Date();
const reportDate = now.toLocaleString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
const html = `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Отчёт о рейсах</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.4; color: #333; background: #f5f5f5; padding: 15px; }
.container { max-width: 1200px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.header { border-bottom: 3px solid #2563eb; padding-bottom: 8px; margin-bottom: 8px; }
.header h1 { color: #1e40af; font-size: 24px; margin-bottom: 4px; }
.header-meta { color: #666; font-size: 13px; }
.sources-info { display: flex; gap: 10px; margin-top: 4px; flex-wrap: wrap; }
.source-tag { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 500; }
.source-tag.available { background: #d1fae5; color: #065f46; }
.source-tag.unavailable { background: #fee2e2; color: #991b1b; }
.flight-card { border: 1px solid #e5e7eb; border-radius: 8px; margin-bottom: 18px; overflow: hidden; background: white; }
.flight-header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 14px 18px; display: flex; justify-content: space-between; align-items: center; }
.flight-header h2 { font-size: 20px; margin: 0; }
.registration { background: rgba(255,255,255,0.2); padding: 4px 10px; border-radius: 4px; font-weight: 600; font-size: 13px; }
.flight-info { padding: 12px 18px; background: #f9fafb; border-bottom: 1px solid #e5e7eb; }
.info-row { display: flex; margin-bottom: 6px; }
.info-row:last-child { margin-bottom: 0; }
.info-row .label { font-weight: 600; color: #4b5563; width: 140px; flex-shrink: 0; font-size: 13px; }
.info-row .value { color: #111827; font-size: 13px; }
.source-section { border-top: 1px solid #e5e7eb; padding: 12px 18px; }
.source-section:first-of-type { border-top: none; }
.source-header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
.source-badge { display: inline-block; padding: 5px 12px; border-radius: 5px; font-size: 12px; font-weight: 600; color: white; }
.source-badge.source-flightaware { background: #3b82f6; }
.source-badge.source-flightradar24 { background: #10b981; }
.source-missing { color: #ef4444; font-size: 12px; font-style: italic; }
.source-content { margin-left: 0; }
.route-info { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px; padding: 12px; background: #f9fafb; border-radius: 6px; }
.route-item { display: flex; flex-direction: column; }
.route-label { font-size: 11px; color: #6b7280; margin-bottom: 3px; text-transform: uppercase; letter-spacing: 0.5px; }
.route-value { font-size: 14px; font-weight: 600; color: #111827; }
.timeline { margin-bottom: 12px; }
.timeline-item { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #e5e7eb; }
.timeline-item:last-child { border-bottom: none; }
.timeline-label { font-weight: 500; color: #4b5563; width: 160px; flex-shrink: 0; font-size: 12px; }
.timeline-value { color: #111827; text-align: right; font-size: 12px; }
.status-info { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; padding: 12px; background: #f9fafb; border-radius: 6px; }
.status-item { display: flex; flex-direction: column; }
.status-label { font-size: 11px; color: #6b7280; margin-bottom: 3px; text-transform: uppercase; letter-spacing: 0.5px; }
.status-value { font-size: 13px; font-weight: 600; color: #111827; }
.delay-negative { color: #10b981; }
.delay-positive { color: #ef4444; }
.no-data { text-align: center; padding: 40px 20px; color: #6b7280; font-size: 16px; }
@media print { body { background: white; padding: 0; } .container { box-shadow: none; padding: 15px; } .flight-card { page-break-inside: avoid; margin-bottom: 15px; } }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Отчёт о рейсах</h1>
<div class="header-meta">
<div>Дата формирования: ${reportDate}</div>
<div class="sources-info">
<span class="source-tag ${flightAwareData.length > 0 ? 'available' : 'unavailable'}">
FlightAware: ${flightAwareData.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
</span>
<span class="source-tag ${flightRadar24Data.length > 0 ? 'available' : 'unavailable'}">
FlightRadar24: ${flightRadar24Data.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
</span>
</div>
</div>
</div>
<div class="flights-container">
${flights.length ? flights.map(f => generateFlightCard(f, flightRadar24Error)).join('') : '<div class="no-data">Данные о рейсах не найдены</div>'}
</div>
</div>
</body>
</html>`;
// ================== HTML → BASE64 ==================
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
// ================== RETURN ==================
return [{
json: {
html_base64: htmlBase64,
html: html,
flights_count: flights.length,
sources: {
flightaware: { available: flightAwareData.length > 0, count: flightAwareData.length },
flightradar24: { available: flightRadar24Data.length > 0, count: flightRadar24Data.length }
},
generated_at: now.toISOString()
}
}];

View File

@@ -0,0 +1,320 @@
# Пример Workflow для обработки рейсов с Base64 PDF
## Структура Workflow
```
HTTP Request (FlightAware)
HTTP Request (FlightRadar24)
Code: Process Flights Data ← N8N_CODE_PROCESS_FLIGHTS_DATA.js
Code: Prepare PDF Request ← N8N_FLIGHTS_PDF_BASE64_COMPLETE.js
HTTP Request (Convert to PDF) ← Внешний сервис конвертации
Code: Extract Base64 PDF ← N8N_FLIGHTS_PDF_BASE64_FULL.js
[Использование base64 PDF]
├─→ Save File
├─→ Send Email
├─→ Upload to S3
└─→ Return in API Response
```
## Детальная настройка нод
### 1. HTTP Request: FlightAware
- **Method:** GET/POST (в зависимости от API)
- **URL:** `https://flightaware.com/api/...`
- **Authentication:** По необходимости
### 2. HTTP Request: FlightRadar24
- **Method:** GET/POST (в зависимости от API)
- **URL:** `https://flightradar24.com/api/...`
- **Authentication:** По необходимости
### 3. Code: Process Flights Data
**Код:** Скопируйте из `N8N_CODE_PROCESS_FLIGHTS_DATA.js`
**Входные данные:**
- Два элемента из предыдущих HTTP Request нод
**Выходные данные:**
```json
{
"html": "<!DOCTYPE html>...",
"flights": [...],
"flights_count": 2,
"sources": {...},
"generated_at": "2026-01-14T..."
}
```
### 4. Code: Prepare PDF Request
**Код:** Скопируйте из `N8N_FLIGHTS_PDF_BASE64_COMPLETE.js`
**Настройка:**
- Замените `PDF_SERVICE_URL` на URL вашего сервиса
- Замените `PDF_API_KEY` на ваш API ключ
**Выходные данные:**
```json
{
"http_method": "POST",
"http_url": "https://api.htmlpdfapi.com/v1/pdf",
"http_headers": {...},
"http_body": "{...}",
"html_length": 12345,
"flights_count": 2
}
```
### 5. HTTP Request: Convert to PDF
**Настройка:**
- **Method:** `{{ $json.http_method }}`
- **URL:** `{{ $json.http_url }}`
- **Authentication:** По необходимости (через Headers)
- **Headers:**
```json
{{ $json.http_headers }}
```
- **Body:**
```json
{{ $json.http_body }}
```
- **Response Format:** `JSON` или `Binary` (зависит от сервиса)
**Пример для htmlpdfapi.com:**
```json
{
"method": "POST",
"url": "https://api.htmlpdfapi.com/v1/pdf",
"headers": {
"Content-Type": "application/json",
"Authorization": "Bearer YOUR_API_KEY"
},
"body": {
"html": "{{ $('Code: Process Flights Data').first().json.html }}",
"options": {
"format": "A4",
"printBackground": true
},
"base64": true
}
}
```
### 6. Code: Extract Base64 PDF
**Код:** Скопируйте из `N8N_FLIGHTS_PDF_BASE64_FULL.js`
**Входные данные:**
- Ответ от HTTP Request ноды (JSON или Binary)
**Выходные данные:**
```json
{
"pdf_base64": "JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9MZW5ndGg...",
"pdf_size_bytes": 123456,
"pdf_size_mb": "0.12",
"success": true,
"source": "json_response"
}
```
## Использование base64 PDF
### Вариант A: Сохранение в файл
**Code Node:**
```javascript
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
const filename = `flights-report-${new Date().toISOString().split('T')[0]}.pdf`;
// Конвертируем base64 в binary
const pdfBuffer = Buffer.from(base64, 'base64');
return [{
binary: {
data: pdfBuffer,
fileName: filename,
mimeType: 'application/pdf'
},
json: {
filename: filename,
size_bytes: pdfBuffer.length
}
}];
```
Затем используйте ноду **Write Binary File** или **Save to S3**.
### Вариант B: Отправка по Email
**Code Node:**
```javascript
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
const pdfBuffer = Buffer.from(base64, 'base64');
return [{
json: {
to: 'recipient@example.com',
subject: 'Отчёт о рейсах',
text: 'Во вложении отчёт о рейсах.',
attachments: [{
filename: 'flights-report.pdf',
content: pdfBuffer,
contentType: 'application/pdf'
}]
}
}];
```
Затем используйте ноду **Email Send**.
### Вариант C: Возврат в API Response
**Code Node (перед Response нодой):**
```javascript
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
const processedData = $('Code: Process Flights Data').first().json;
return [{
json: {
success: true,
flights_count: processedData.flights_count,
pdf_base64: base64,
pdf_size_mb: $('Code: Extract Base64 PDF').first().json.pdf_size_mb,
generated_at: processedData.generated_at
}
}];
```
### Вариант D: Загрузка в S3/Nextcloud
**Code Node:**
```javascript
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
const pdfBuffer = Buffer.from(base64, 'base64');
const filename = `flights-report-${new Date().toISOString().split('T')[0]}.pdf`;
return [{
binary: {
data: pdfBuffer,
fileName: filename,
mimeType: 'application/pdf'
},
json: {
bucket: 'your-bucket',
key: `reports/${filename}`,
contentType: 'application/pdf'
}
}];
```
Затем используйте ноду **S3 Upload** или **Nextcloud Upload**.
## Альтернативные сервисы конвертации
### 1. htmlpdfapi.com
```javascript
{
"url": "https://api.htmlpdfapi.com/v1/pdf",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "Bearer YOUR_API_KEY"
},
"body": {
"html": "{{ HTML }}",
"base64": true
}
}
```
### 2. pdfshift.io
```javascript
{
"url": "https://api.pdfshift.io/v3/convert/pdf",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "Basic " + Buffer.from("api:YOUR_API_KEY").toString("base64")
},
"body": {
"source": "{{ HTML }}",
"format": "A4"
}
}
// Ответ содержит base64 в поле "pdf"
```
### 3. api2pdf.com
```javascript
{
"url": "https://v2.api2pdf.com/chrome/html",
"method": "POST",
"headers": {
"Authorization": "YOUR_API_KEY",
"Content-Type": "application/json"
},
"body": {
"html": "{{ HTML }}",
"inlinePdf": true,
"fileName": "flights-report.pdf"
}
}
// Ответ содержит base64 в поле "pdf"
```
### 4. Self-hosted: Gotenberg
```javascript
{
"url": "http://your-gotenberg-server:3000/forms/chromium/convert/html",
"method": "POST",
"headers": {
"Content-Type": "multipart/form-data"
},
"body": {
"files": [{
"name": "index.html",
"content": "{{ HTML }}"
}]
}
}
// Ответ - binary PDF, конвертируем в base64
```
## Отладка
### Проверка HTML
```javascript
const html = $('Code: Process Flights Data').first().json.html;
console.log('HTML length:', html.length);
console.log('HTML preview:', html.substring(0, 500));
```
### Проверка base64
```javascript
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
console.log('Base64 length:', base64.length);
console.log('Base64 preview:', base64.substring(0, 100));
```
### Проверка размера PDF
```javascript
const data = $('Code: Extract Base64 PDF').first().json;
console.log('PDF size:', data.pdf_size_mb, 'MB');
console.log('PDF size bytes:', data.pdf_size_bytes);
```
## Обработка ошибок
Добавьте IF Node после HTTP Request для проверки успешности:
```javascript
// IF Node: Check PDF Conversion Success
{{ $json.success === true }}
```
Если ошибка - отправьте уведомление или сохраните HTML для ручной конвертации.

View File

@@ -0,0 +1,140 @@
# ✅ Рабочее решение: Обработка данных о рейсах → PDF
## Структура Workflow
```
[Входные данные: FlightAware + FlightRadar24]
[Code: причесываем данные] ← Генерирует HTML и конвертирует в base64
[HTTP Request: Browserless PDF] ← Конвертирует HTML в PDF через браузер
[Результат: PDF binary]
```
---
## Нода 1: Code - "причесываем данные"
**Тип:** Code (JavaScript)
**Код:** См. файл `N8N_FLIGHTS_TO_BASE64.js`
**Что делает:**
1. Извлекает данные из структуры `[{ data: [{ body: { flights: [...] }}, { body: { data: [...] }}] }]`
2. Объединяет рейсы по `registration` (номер самолёта)
3. Генерирует красивый HTML с CSS
4. Конвертирует HTML в base64
**Выходные данные:**
```json
{
"html_base64": "PCFET0NUWVBFIGh0bWw+...",
"html": "<!DOCTYPE html>...",
"flights_count": 2,
"sources": {
"flightaware": { "available": true, "count": 2 },
"flightradar24": { "available": true, "count": 2 }
},
"generated_at": "2026-01-16T07:23:00.000Z"
}
```
---
## Нода 2: HTTP Request - "Browserless PDF"
**Тип:** HTTP Request
**Настройки:**
- **Method:** `POST`
- **URL:** `http://147.45.146.17:3000/pdf?token=9ahhnpjkchxtcho9`
- **Send Body:** ✅ Да
- **Specify Body:** `JSON`
- **JSON Body:**
```json
{
"url": "data:text/html;base64, {{ $json.html_base64 }}",
"options": {
"format": "A4",
"printBackground": true,
"margin": {
"top": "20mm",
"right": "15mm",
"bottom": "20mm",
"left": "20mm"
}
}
}
```
**Response Format:** `Binary` (или `JSON`, если Browserless возвращает JSON)
---
## Результат
HTTP Request нода вернёт PDF в binary формате, который можно:
- Сохранить в файл
- Отправить по email
- Загрузить в S3/Nextcloud
- Конвертировать в base64 для API response
---
## Конвертация Binary PDF → Base64 (опционально)
Если нужен base64 PDF, добавьте Code Node после HTTP Request:
```javascript
const pdfBinary = $binary.data;
const base64 = Buffer.isBuffer(pdfBinary)
? pdfBinary.toString('base64')
: Buffer.from(pdfBinary).toString('base64');
const sizeBytes = Buffer.from(base64, 'base64').length;
return [{
json: {
pdf_base64: base64,
pdf_size_bytes: sizeBytes,
pdf_size_mb: (sizeBytes / (1024 * 1024)).toFixed(2),
success: true
}
}];
```
---
## Преимущества решения
**Простота** - всего 2 ноды
**Надёжность** - Browserless использует реальный браузер
**Качество** - PDF с правильным форматированием и стилями
**Гибкость** - можно легко изменить параметры PDF (формат, отступы)
---
## Отладка
Если что-то не работает:
1. **Проверьте HTML** - в Code Node добавьте:
```javascript
console.log('HTML length:', html.length);
console.log('HTML preview:', html.substring(0, 200));
```
2. **Проверьте base64** - в Code Node добавьте:
```javascript
console.log('Base64 length:', htmlBase64.length);
```
3. **Проверьте ответ Browserless** - в HTTP Request включите "Always Output Data" и проверьте ответ
---
## Готово! 🎉
Workflow работает и генерирует красивые PDF отчёты о рейсах!

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,102 @@
// ============================================================================
// n8n Code Node: HTML → Base64 PDF (простой вариант)
// ============================================================================
// Используйте этот код ПОСЛЕ ноды, которая вернула HTML
// Этот код подготовит запрос для HTTP Request ноды
// ============================================================================
// Получаем HTML из предыдущей ноды
// Если HTML пришёл в поле "html", используем его
const html = $json.html || $json.body?.html || $json;
if (!html || typeof html !== 'string') {
throw new Error('HTML не найден в входных данных. Проверьте структуру данных.');
}
console.log('📄 HTML получен, длина:', html.length);
// ==== НАСТРОЙКИ СЕРВИСА КОНВЕРТАЦИИ ====
// Выберите один из вариантов ниже и раскомментируйте его
// ==== ВАРИАНТ 1: htmlpdfapi.com (рекомендуется) ====
// Бесплатный план: 100 PDF в месяц
// URL: https://htmlpdfapi.com
return [{
json: {
// Данные для HTTP Request ноды
method: 'POST',
url: 'https://api.htmlpdfapi.com/v1/pdf',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_API_KEY' // ⚠️ ЗАМЕНИТЕ на ваш API ключ
},
body: JSON.stringify({
html: html,
options: {
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm'
}
},
base64: true // Запрашиваем base64 напрямую
})
}
}];
// ==== ВАРИАНТ 2: pdfshift.io ====
// Раскомментируйте, если используете pdfshift.io
/*
return [{
json: {
method: 'POST',
url: 'https://api.pdfshift.io/v3/convert/pdf',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Basic ' + Buffer.from('api:YOUR_API_KEY').toString('base64')
},
body: JSON.stringify({
source: html,
format: 'A4',
margin: '20mm'
})
}
}];
*/
// ==== ВАРИАНТ 3: api2pdf.com ====
// Раскомментируйте, если используете api2pdf.com
/*
return [{
json: {
method: 'POST',
url: 'https://v2.api2pdf.com/chrome/html',
headers: {
'Authorization': 'YOUR_API_KEY',
'Content-Type': 'application/json'
},
body: JSON.stringify({
html: html,
inlinePdf: true,
fileName: 'flights-report.pdf'
})
}
}];
*/
// ============================================================================
// ИНСТРУКЦИЯ:
// ============================================================================
// 1. Этот Code Node подготавливает запрос
// 2. Добавьте HTTP Request ноду после этого Code Node
// 3. В HTTP Request ноде настройте:
// - Method: {{ $json.method }}
// - URL: {{ $json.url }}
// - Headers: {{ $json.headers }}
// - Body: {{ $json.body }}
// 4. После HTTP Request добавьте Code Node с кодом из N8N_EXTRACT_BASE64_FROM_RESPONSE.js
// для извлечения base64 из ответа
// ============================================================================

View File

@@ -0,0 +1,62 @@
/**
* n8n Code node: парсинг сырого init_data из Telegram WebApp
*
* Вход: объект с полем init_data (строка query string от Telegram).
* Выход: тот же объект + поля init_data_parsed и user_decoded.
*
* Подключение: после Webhook — в Code передаётся $input.item.json.
* init_data должен быть в $json.init_data (как шлёт наш бэкенд).
*/
const item = $input.first().json;
// Сырая строка init_data (query string)
const rawInitData = item.init_data || item.body?.init_data || '';
if (!rawInitData) {
return [{ json: { ...item, init_data_error: 'init_data отсутствует' } }];
}
/**
* Парсит query string в объект (значения URL-декодированы)
*/
function parseQueryString(qs) {
const result = {};
const pairs = qs.split('&');
for (const pair of pairs) {
const [key, value] = pair.split('=').map(s => decodeURIComponent(s || ''));
if (key) result[key] = value;
}
return result;
}
const parsed = parseQueryString(rawInitData);
// user приходит как URL-encoded JSON строка
let userDecoded = null;
if (parsed.user) {
try {
userDecoded = JSON.parse(parsed.user);
} catch (e) {
userDecoded = { _parse_error: String(e), raw: parsed.user };
}
}
return [{
json: {
...item,
init_data_parsed: {
query_id: parsed.query_id || null,
auth_date: parsed.auth_date ? parseInt(parsed.auth_date, 10) : null,
hash: parsed.hash || null,
signature: parsed.signature || null,
user_raw: parsed.user || null,
},
user_decoded: userDecoded,
// удобные поля для маппинга в CRM
telegram_user_id: userDecoded?.id ?? null,
telegram_username: userDecoded?.username ?? null,
telegram_first_name: userDecoded?.first_name ?? null,
telegram_last_name: userDecoded?.last_name ?? null,
},
}];

View File

@@ -0,0 +1,147 @@
# 🔧 Решение проблемы зависших n8n workflow
## 🐛 Проблема
Workflow в n8n зависает и не может быть перезапущен даже через интерфейс. Redis Trigger node теряет соединение и не переподключается автоматически.
## ✅ Что сделано
### 1. Улучшена логика перезапуска workflow
**Файл:** `backend/app/services/n8n_service.py`
**Изменения:**
- ✅ Увеличены таймауты с 10 до 30 секунд (общий) и 15 секунд (для отдельных операций)
- ✅ Добавлена обработка таймаутов при деактивации (продолжаем даже если деактивация зависла)
- ✅ Увеличена задержка между деактивацией и активацией (3 секунды вместо 2)
- ✅ Добавлена дополнительная задержка после активации для инициализации trigger node
- ✅ Улучшено логирование ошибок с полным traceback
### 2. Улучшена проверка и перезапуск в фоне
**Файл:** `backend/app/api/claims.py`
**Изменения:**
- ✅ Добавлены повторные попытки перезапуска (до 2 попыток)
- ✅ Добавлена проверка подписчиков после перезапуска
- ✅ Улучшено логирование процесса перезапуска
## 🚀 Как это работает
1. **При публикации сообщения в Redis:**
- Проверяется количество подписчиков
- Если подписчиков нет → сообщение сохраняется в буфер
- Запускается фоновая задача перезапуска workflow
2. **Процесс перезапуска:**
- Проверяется Redis lock (защита от частых перезапусков)
- Проверяется статус workflow через API
- Деактивируется workflow (даже если завис)
- Ждёт 3 секунды
- Активирует workflow
- Ждёт 2 секунды для инициализации
- Проверяет подписчиков
- Отправляет сообщения из буфера
3. **Повторные попытки:**
- Если первая попытка не удалась → повтор через 5 секунд
- Максимум 2 попытки
## 📊 Мониторинг
### Проверка подписчиков вручную:
```bash
redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PUBSUB NUMSUB "ticket_form:description"
```
### Проверка статуса workflow:
```bash
curl -H "X-N8N-API-KEY: ..." "https://n8n.clientright.pro/api/v1/workflows/b4K4u851b4JFivyD" | jq '.active'
```
### Логи backend:
```bash
tail -f /var/www/fastuser/data/www/crm.clientright.ru/ticket_form/backend.log | grep -i "workflow\|redis\|subscriber"
```
## 🛠️ Если проблема повторится
### Вариант 1: Перезапуск через API (автоматически)
Код теперь автоматически пытается перезапустить workflow при обнаружении проблемы.
### Вариант 2: Ручной перезапуск через API
```bash
# Деактивировать
curl -X POST -H "X-N8N-API-KEY: ..." \
"https://n8n.clientright.pro/api/v1/workflows/b4K4u851b4JFivyD/deactivate"
# Подождать 5 секунд
sleep 5
# Активировать
curl -X POST -H "X-N8N-API-KEY: ..." \
"https://n8n.clientright.pro/api/v1/workflows/b4K4u851b4JFivyD/activate"
```
### Вариант 3: Перезапуск n8n (крайний случай)
Если workflow всё ещё завис, может потребоваться перезапуск самого n8n:
```bash
# Если n8n в Docker
docker restart <n8n_container>
# Если n8n как системный сервис
systemctl restart n8n
```
## 🔍 Диагностика
### Проверка что workflow активен но не слушает:
```bash
# 1. Проверить статус workflow
curl -H "X-N8N-API-KEY: ..." \
"https://n8n.clientright.pro/api/v1/workflows/b4K4u851b4JFivyD" | jq '{active: .active, updatedAt: .updatedAt}'
# 2. Проверить подписчиков
redis-cli -h crm.clientright.ru -p 6379 -a "..." PUBSUB NUMSUB "ticket_form:description"
# 3. Если active=true но подписчиков 0 → workflow завис
```
### Проверка Redis соединений:
```bash
redis-cli -h crm.clientright.ru -p 6379 -a "..." CLIENT LIST | grep "sub=1"
```
## 📝 Рекомендации на будущее
1. **Мониторинг:**
- Настроить автоматический мониторинг подписчиков (cron каждые 5 минут)
- Алерты при отсутствии подписчиков более 10 минут
2. **Автоматический перезапуск n8n:**
- Настроить health check для n8n
- Автоматический перезапуск при обнаружении проблем
3. **Логирование:**
- Включить детальное логирование в n8n
- Мониторинг логов на ошибки Redis соединений
4. **Настройка Redis:**
- Увеличить `tcp-keepalive` для стабильности соединений
- Настроить `timeout` для неактивных соединений
## 🔗 Связанные файлы
- `backend/app/services/n8n_service.py` - логика перезапуска workflow
- `backend/app/api/claims.py` - проверка подписчиков и запуск перезапуска
- `docs/N8N_REDIS_TRIGGER_TROUBLESHOOTING.md` - общая диагностика Redis Trigger
- `docs/N8N_MEMORY_ISSUES.md` - проблемы с памятью в n8n

View File

@@ -0,0 +1,122 @@
# Как срабатывает Telegram Mini App (по шагам)
Ты в Telegram нажимаешь кнопку «Открыть мини-апп» → открывается **aiform.clientright.ru**. Ниже — что происходит дальше и где.
---
## 1. Где открывается страница
- **Кто:** Telegram (клиент на телефоне/десктопе).
- **Что:** Открывает aiform.clientright.ru **в своём встроенном браузере (WebView)** как Mini App.
- **Важно:** В этом режиме Telegram сам подставляет в страницу свой скрипт и объект `window.Telegram.WebApp` с полем **initData** (подпись пользователя и данные). В обычном браузере по прямой ссылке этого объекта нет.
---
## 2. Загрузка фронта (aiform.clientright.ru)
- Загружается твой SPA (React): главная страница — форма заявки **ClaimForm**.
- Рендерится первый экран формы (шаг 0).
- Сразу при монтировании компонента запускается **useEffect** с функцией `tryTelegramAuth()``ClaimForm.tsx`).
**Где в коде:** `frontend/src/pages/ClaimForm.tsx`, блок «Telegram Mini App: попытка авторизоваться через initData при первом заходе».
---
## 3. Проверка: это Mini App или обычный сайт?
Фронт делает:
1. Смотрит, есть ли `window.Telegram?.WebApp?.initData`.
2. Если нет — ждёт 300 ms (на случай асинхронной подгрузки скрипта Telegram) и проверяет снова.
3. Если после этого **нет** `initData` → в консоль пишется «Telegram WebApp не обнаружен», авторизация по Telegram **не вызывается**, форма ведёт себя как обычный веб-сайт (SMS, сессия из localStorage и т.д.).
4. Если **есть** `initData`:
- Проверяет, есть ли уже в **localStorage** ключ `session_token`.
- Если **есть** → считаем, что пользователь уже залогинен, tg/auth не вызываем, дальше работает обычное восстановление сессии.
- Если **нет** → идём в шаг 4.
**Итого:** срабатывание tg/auth **только** когда:
- страница открыта **из Telegram** (есть `initData`),
- и в localStorage **нет** сохранённого `session_token`.
---
## 4. Запрос на бэкенд: POST /api/v1/tg/auth
- **Кто:** фронт (ClaimForm).
- **Куда:** на тот же домен aiform.clientright.ru → запрос уходит на твой backend (через nginx/proxy на порт 8200).
- **URL:** `POST /api/v1/tg/auth`.
- **Тело:** `{ "init_data": "<строка initData от Telegram>" }`.
**Где в коде:** `ClaimForm.tsx``fetch('/api/v1/tg/auth', { method: 'POST', body: JSON.stringify({ init_data: webApp.initData }) })`.
---
## 5. Обработка на бэкенде (tg/auth)
- **Где:** `backend/app/api/telegram_auth.py`, эндпоинт `POST /api/v1/tg/auth`.
Последовательно:
1. **Валидация initData** (`backend/app/services/telegram_auth.py`):
- Проверка подписи через **TELEGRAM_BOT_TOKEN** из `.env`.
- Если токена нет или подпись не совпадает → ответ **400** (или 500), фронт пишет «Telegram auth failed» и ведёт себя как обычный сайт.
2. **Извлечение пользователя Telegram:** из initData достаются `id`, `username`, `first_name`, `last_name`.
3. **Запрос в n8n:**
- Бэкенд дергает **N8N_TG_AUTH_WEBHOOK** (URL из `.env`).
- Передаёт: `telegram_user_id`, `username`, `first_name`, `last_name`, `session_token`, `form_id`.
- Ожидает в ответе минимум **unified_id** (и при необходимости contact_id, phone, has_drafts).
4. **Создание сессии в Redis:**
- По `session_token` + `unified_id` (+ phone, contact_id) создаётся запись сессии (как после SMS-логина).
5. **Ответ фронту:**
`{ success: true, session_token, unified_id, contact_id?, phone?, has_drafts? }`.
Если на любом шаге ошибка (нет токена, n8n не вернул unified_id и т.д.) — бэкенд отдаёт ошибку, фронт считает tg/auth неуспешным и продолжает как обычный веб.
---
## 6. Что делает фронт после успешного ответа
- Сохраняет **session_token** в **localStorage** и в `sessionIdRef`.
- Обновляет состояние формы: `unified_id`, `phone`, `contact_id`, `session_id`.
- Ставит **isPhoneVerified = true** (шаг «телефон» считаем пройденным).
- Если в ответе **has_drafts === true** → показывает экран выбора черновиков.
- Если **has_drafts** нет или false → переводит на **шаг 1** (описание проблемы).
Дальше пользователь идёт по форме как обычно: описание → черновик/визард → подтверждение → оплата и т.д., но уже без ввода телефона и SMS, потому что он «залогинен» через Telegram.
---
## Сводка: где что срабатывает
| Шаг | Где | Что происходит |
|-----|-----|----------------|
| 1 | Telegram | Открывает aiform.clientright.ru в WebView, подставляет WebApp и initData |
| 2 | Браузер (WebView) | Загружается SPA, монтируется ClaimForm |
| 3 | ClaimForm.tsx (фронт) | Проверка: есть ли Telegram.WebApp.initData и нет ли session_token в localStorage |
| 4 | ClaimForm.tsx (фронт) | POST /api/v1/tg/auth с init_data |
| 5 | telegram_auth.py (бэкенд) | Валидация initData, запрос в n8n, создание сессии в Redis |
| 6 | ClaimForm.tsx (фронт) | Сохранение session_token, переход на шаг черновиков или описание |
---
## Если открыть aiform.clientright.ru не из Telegram
- В обычном браузере (Chrome, Safari по прямой ссылке) **нет** `window.Telegram.WebApp`.
- Фронт пишет в консоль «Telegram WebApp не обнаружен» и **не вызывает** /api/v1/tg/auth.
- Работает обычный сценарий: ввод телефона → SMS → сессия и т.д.
---
## Что должно быть настроено
1. **В Telegram:** у бота должна быть кнопка/меню, открывающее Mini App с URL **https://aiform.clientright.ru** (или с путём на эту форму).
2. **Backend .env:**
- **TELEGRAM_BOT_TOKEN** — токен этого же бота (для проверки initData).
- **N8N_TG_AUTH_WEBHOOK** — URL webhook в n8n, который по telegram_user_id возвращает unified_id (и при необходимости contact_id, phone, has_drafts).
3. **n8n:** workflow по этому webhook принимает JSON с telegram_user_id и т.д. и отдаёт JSON с полем **unified_id** (обязательно).
Если что-то из этого не настроено, цепочка обрывается на шаге 5 (бэкенд/n8n), и пользователь остаётся в «обычном» режиме формы без авторизации через Telegram.