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:
68
docs/BROWSERLESS_CURL_EXAMPLE.sh
Normal file
68
docs/BROWSERLESS_CURL_EXAMPLE.sh
Normal 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
|
||||
# ============================================================================
|
||||
163
docs/N8N_BROWSERLESS_FUNCTION_GUIDE.md
Normal file
163
docs/N8N_BROWSERLESS_FUNCTION_GUIDE.md
Normal 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
|
||||
29
docs/N8N_BROWSERLESS_FUNCTION_SETUP.json
Normal file
29
docs/N8N_BROWSERLESS_FUNCTION_SETUP.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
135
docs/N8N_BROWSERLESS_HTTP_REQUEST_SETUP.md
Normal file
135
docs/N8N_BROWSERLESS_HTTP_REQUEST_SETUP.md
Normal 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
|
||||
698
docs/N8N_CODE_PROCESS_FLIGHTS_DATA.js
Normal file
698
docs/N8N_CODE_PROCESS_FLIGHTS_DATA.js
Normal 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
|
||||
}
|
||||
}];
|
||||
110
docs/N8N_EXTRACT_BASE64_FROM_RESPONSE.js
Normal file
110
docs/N8N_EXTRACT_BASE64_FROM_RESPONSE.js
Normal 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
|
||||
// ============================================================================
|
||||
99
docs/N8N_FLIGHTS_BROWSERLESS_COMPLETE.js
Normal file
99
docs/N8N_FLIGHTS_BROWSERLESS_COMPLETE.js
Normal 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
|
||||
// }
|
||||
// }];
|
||||
// ============================================================================
|
||||
99
docs/N8N_FLIGHTS_BROWSERLESS_PDF.js
Normal file
99
docs/N8N_FLIGHTS_BROWSERLESS_PDF.js
Normal 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)
|
||||
// ============================================================================
|
||||
124
docs/N8N_FLIGHTS_BROWSERLESS_PDF_V2.js
Normal file
124
docs/N8N_FLIGHTS_BROWSERLESS_PDF_V2.js
Normal 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
|
||||
// ============================================================================
|
||||
112
docs/N8N_FLIGHTS_COMPLETE_WORKFLOW.md
Normal file
112
docs/N8N_FLIGHTS_COMPLETE_WORKFLOW.md
Normal 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/месяц бесплатно
|
||||
132
docs/N8N_FLIGHTS_HTML_TO_PDF_BROWSER.js
Normal file
132
docs/N8N_FLIGHTS_HTML_TO_PDF_BROWSER.js
Normal 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 для конвертации
|
||||
// ============================================================================
|
||||
96
docs/N8N_FLIGHTS_HTML_TO_PDF_EXAMPLE.js
Normal file
96
docs/N8N_FLIGHTS_HTML_TO_PDF_EXAMPLE.js
Normal 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
|
||||
}
|
||||
}];
|
||||
*/
|
||||
35
docs/N8N_FLIGHTS_IMPROVED_FORMATTING.md
Normal file
35
docs/N8N_FLIGHTS_IMPROVED_FORMATTING.md
Normal 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 "причесываем данные".
|
||||
72
docs/N8N_FLIGHTS_PDF_BASE64_COMPLETE.js
Normal file
72
docs/N8N_FLIGHTS_PDF_BASE64_COMPLETE.js
Normal 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 из ответа
|
||||
// ============================================================================
|
||||
81
docs/N8N_FLIGHTS_PDF_BASE64_FULL.js
Normal file
81
docs/N8N_FLIGHTS_PDF_BASE64_FULL.js
Normal 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));
|
||||
65
docs/N8N_FLIGHTS_PREPARE_REQUEST_DATA.js
Normal file
65
docs/N8N_FLIGHTS_PREPARE_REQUEST_DATA.js
Normal 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
|
||||
}
|
||||
}];
|
||||
193
docs/N8N_FLIGHTS_PROCESSING_GUIDE.md
Normal file
193
docs/N8N_FLIGHTS_PROCESSING_GUIDE.md
Normal 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;
|
||||
});
|
||||
```
|
||||
236
docs/N8N_FLIGHTS_QUICK_START.md
Normal file
236
docs/N8N_FLIGHTS_QUICK_START.md
Normal 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]
|
||||
```
|
||||
|
||||
Всё готово! 🎉
|
||||
103
docs/N8N_FLIGHTS_REQUESTED_INFO.md
Normal file
103
docs/N8N_FLIGHTS_REQUESTED_INFO.md
Normal 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 - там будут сообщения о найденных запрошенных рейсах
|
||||
370
docs/N8N_FLIGHTS_SIMPLE_BINARY.js
Normal file
370
docs/N8N_FLIGHTS_SIMPLE_BINARY.js
Normal 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 из ответа
|
||||
// ============================================================================
|
||||
630
docs/N8N_FLIGHTS_TO_BASE64.js
Normal file
630
docs/N8N_FLIGHTS_TO_BASE64.js
Normal 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()
|
||||
}
|
||||
}];
|
||||
320
docs/N8N_FLIGHTS_WORKFLOW_EXAMPLE.md
Normal file
320
docs/N8N_FLIGHTS_WORKFLOW_EXAMPLE.md
Normal 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 для ручной конвертации.
|
||||
140
docs/N8N_FLIGHTS_WORKING_SOLUTION.md
Normal file
140
docs/N8N_FLIGHTS_WORKING_SOLUTION.md
Normal 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 отчёты о рейсах!
|
||||
53
docs/N8N_FLIGHTS_WORKING_WORKFLOW.json
Normal file
53
docs/N8N_FLIGHTS_WORKING_WORKFLOW.json
Normal file
File diff suppressed because one or more lines are too long
102
docs/N8N_HTML_TO_BASE64_PDF_SIMPLE.js
Normal file
102
docs/N8N_HTML_TO_BASE64_PDF_SIMPLE.js
Normal 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 из ответа
|
||||
// ============================================================================
|
||||
62
docs/N8N_PARSE_INIT_DATA.js
Normal file
62
docs/N8N_PARSE_INIT_DATA.js
Normal 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,
|
||||
},
|
||||
}];
|
||||
147
docs/N8N_WORKFLOW_STUCK_FIX.md
Normal file
147
docs/N8N_WORKFLOW_STUCK_FIX.md
Normal 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
|
||||
122
docs/TELEGRAM_MINIAPP_FLOW.md
Normal file
122
docs/TELEGRAM_MINIAPP_FLOW.md
Normal 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.
|
||||
Reference in New Issue
Block a user