The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"name": "Flugsuche Explore (Bulgaria Christmas 2026)",
"nodes": [
{
"parameters": {},
"id": "cbfbf6b6-785c-4fd3-abaf-7f03ae0adba7",
"name": "Manuell starten",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
-320,
0
]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "cfg-origins",
"name": "origins",
"value": "HHN, DTM, EIN, CRL, CGN, FRA",
"type": "string"
},
{
"id": "cfg-from",
"name": "dateFrom",
"value": "2026-12-23",
"type": "string"
},
{
"id": "cfg-to",
"name": "dateTo",
"value": "2027-01-06",
"type": "string"
},
{
"id": "cfg-mindays",
"name": "minDays",
"value": 12,
"type": "number"
},
{
"id": "cfg-pax",
"name": "erwachsene",
"value": 5,
"type": "number"
},
{
"id": "cfg-direkt",
"name": "nurDirekt",
"value": true,
"type": "boolean"
},
{
"id": "cfg-ziel",
"name": "ziel",
"value": "",
"type": "string"
}
]
},
"options": {}
},
"id": "7f26e1b9-92e7-42f7-b521-9932a1fc3c64",
"name": "Konfig",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
-128,
0
]
},
{
"parameters": {
"jsCode": "// Job-Generator f\u00fcr Google-Flights \"Explore\" (anywhere). Wird wortgleich in den\n// n8n-Code-Node \"Jobs generieren\" kopiert. Keine externen Abh\u00e4ngigkeiten.\nconst BASE_TFS = 'CBwQAxojEgoyMDI2LTEwLTIwagcIARIDRUlOcgwIBBIIL20vMDJqNzEaIxIKMjAyNi0xMC0zMGoMCAQSCC9tLzAyajcxcgcIARIDRUlOQAFIAXABggEECAkQA5gBAbIBBBgBIAE';\n// Nonstop-Variante (live aus der Explore-UI \u201eNur Nonstop-Fl\u00fcge\" eingefangen): pro\n// Slice ist direkt nach dem Datum ein `28 00` (Feld 5 = 0 = nur Direktflug) eingef\u00fcgt.\n// Eigene Basis-Vorlage statt Byte-Insert, weil das Einf\u00fcgen sonst die Slice-L\u00e4ngen-\n// Pr\u00e4fixe (1a 23 -> 1a 25) brechen w\u00fcrde; Origin/Datum/Pax sind hier gleich substituierbar.\nconst BASE_TFS_NONSTOP = 'CBwQAxolEgoyMDI2LTEwLTIwKABqBwgBEgNFSU5yDAgEEggvbS8wMmo3MRolEgoyMDI2LTEwLTMwKABqDAgEEggvbS8wMmo3MXIHCAESA0VJTkABSAFwAYIBBAgJEAOYAQGyAQQYASAB';\nconst TFU = 'GioaKAoSCZT42FsUmUVAEZaEQsDfFjlAEhIJRhogG-qzQUARLAmFgD-SIUA';\n\nconst b64urlDecode = s => Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('latin1');\nconst b64urlEncode = s => Buffer.from(s, 'latin1').toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\n\n// EIN->origin (beide Slices), Daten ersetzen, Pax = N x (40 01) direkt vor 48 01.\n// nurDirekt -> Nonstop-Basis-Vorlage (sonst \u201ebeliebige Stopps\").\nfunction buildTfs(origin, out, ret, erwachsene, nurDirekt = false) {\n let s = b64urlDecode(nurDirekt ? BASE_TFS_NONSTOP : BASE_TFS);\n s = s.split('EIN').join(origin);\n s = s.split('2026-10-20').join(out);\n s = s.split('2026-10-30').join(ret);\n const pax = '\\x40\\x01'.repeat(Math.max(1, erwachsene));\n s = s.replace(/(?:\\x40\\x01)+\\x48\\x01/, pax + '\\x48\\x01');\n return b64urlEncode(s);\n}\n\nfunction buildUrl(origin, out, ret, erwachsene, nurDirekt = false) {\n const tfs = buildTfs(origin, out, ret, erwachsene, nurDirekt);\n return `https://www.google.com/travel/explore?tfs=${tfs}&tfu=${TFU}&hl=de&curr=EUR`;\n}\n\n// Abflug ab dateFrom; R\u00fcckflug von out+minDays bis dateTo (alle l\u00e4ngeren Aufenthalte).\nfunction datePairs(dateFrom, dateTo, minDays) {\n const DAY = 86400000;\n const from = new Date(dateFrom + 'T00:00:00Z');\n const to = new Date(dateTo + 'T00:00:00Z');\n const iso = d => d.toISOString().slice(0, 10);\n const pairs = [];\n for (let o = new Date(from); o <= to; o = new Date(+o + DAY)) {\n for (let r = new Date(+o + minDays * DAY); r <= to; r = new Date(+r + DAY)) {\n pairs.push({ out: iso(o), ret: iso(r) });\n }\n }\n return pairs;\n}\n\n// Destinations-Deep-Link: h\u00e4ngt die Ziel-MID als Ortsfilter an die Job-tfs (Feld 22,\n// Sub-Feld 5) \u2014 exakt das, was ein Klick auf eine Explore-Kachel erzeugt (live\n// verifiziert). Die MID stammt aus dem `data-code` der <li>-Ziel-Kachel. WORTGLEICH\n// (funktional) in server.js kopiert. Origin/Datum/Nonstop der Job-tfs bleiben erhalten.\nfunction exploreDeepLink(tfs, mid) {\n if (!mid) return null;\n const place = '\\x2a' + String.fromCharCode(4 + mid.length) + '\\x08\\x02\\x12' + String.fromCharCode(mid.length) + mid;\n const content = '\\x18\\x01\\x20\\x01' + place; // f22-Inhalt: Default + Ort\n const newF22 = '\\xb2\\x01' + String.fromCharCode(content.length) + content;\n const s = b64urlDecode(tfs).replace('\\xb2\\x01\\x04\\x18\\x01\\x20\\x01', newF22);\n return `https://www.google.com/travel/explore?tfs=${b64urlEncode(s)}&tfu=${TFU}&hl=de&curr=EUR`;\n}\n\nfunction generateJobs(cfg) {\n const origins = String(cfg.origins).split(',').map(s => s.trim().toUpperCase()).filter(Boolean);\n const erwachsene = Number(cfg.erwachsene) || 1;\n // Default: nur Direktfl\u00fcge. false nur bei explizitem false/\"false\".\n const nurDirekt = !(cfg.nurDirekt === false || cfg.nurDirekt === 'false');\n const pairs = datePairs(cfg.dateFrom, cfg.dateTo, Number(cfg.minDays));\n const jobs = [];\n for (const origin of origins) {\n for (const { out, ret } of pairs) {\n jobs.push({ id: `${origin}|${out}|${ret}`, url: buildUrl(origin, out, ret, erwachsene, nurDirekt) });\n }\n }\n return jobs;\n}\n\n// \u2500\u2500 n8n-Treiber \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// State-Datei zu Lauf-Beginn zur\u00fccksetzen: sonst h\u00e4ngt ein neuer Lauf seine Jobs an\n// die Altlast eines zuvor ABGEBROCHENEN Laufs an (Aggregieren l\u00f6scht sie nur am Ende).\n// So spiegelt der Output immer NUR den aktuellen Lauf -> Dateien je Run frisch.\ntry { require('fs').unlinkSync('/tmp/flights_explore_state.json'); } catch (e) {}\nconst cfg = $('Konfig').first().json;\nreturn generateJobs(cfg).map(j => ({ json: j }));\n"
},
"id": "5203d989-fe6e-43ac-b422-feb750136c5c",
"name": "Jobs generieren",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
64,
0
]
},
{
"parameters": {
"batchSize": 5,
"options": {}
},
"id": "17fd5fa4-c93b-4e15-a328-d63b15c9a13f",
"name": "Batches",
"type": "n8n-nodes-base.splitInBatches",
"typeVersion": 3,
"position": [
256,
0
]
},
{
"parameters": {
"jsCode": "// Ein Batch (<= batchSize Jobs) an /flights-explore schicken und die per-Job-\n// Ergebnisse an eine State-Datei anh\u00e4ngen. Der HTTP-Aufruf l\u00e4uft per http.request\n// INNERHALB des Code-Nodes (wie 'Daten abrufen' der Dividenden-Analyse) \u2014 der\n// n8n-HTTP-Request-Node h\u00e4ngt sonst ein rohes IncomingMessage-Objekt ans Item,\n// das beim Serialisieren in den JS-Task-Runner eine zirkul\u00e4re Struktur wirft.\n// Akkumulation \u00fcber die Datei, weil n8n-Loop-Scoping sonst nur den letzten Batch s\u00e4he.\nconst http = require('http');\nconst fs = require('fs');\nconst STATE = '/tmp/flights_explore_state.json';\nconst jobs = $input.all().map(i => i.json);\nconst body = JSON.stringify({ jobs, politenessMs: 1200, maxRetries: 5 });\n// HTTP-Timeout 1500s: bei Google-Drosselung kann ein 5er-Batch (5 Retries/Job) mehrere\n// Minuten brauchen; muss unter N8N_RUNNERS_TASK_TIMEOUT (1800s) bleiben, aber deutlich\n// \u00fcber dem Worst Case (~8min), damit der Endpoint mit failed-Jobs antwortet statt der\n// http.request vorher abzubrechen (sonst crasht der ganze Lauf).\n// Antwort robust einlesen: Status pr\u00fcfen, nur JSON parsen. Eine nicht-JSON-Antwort\n// (HTML-Fehlerseite, leeres Paket, 5xx unter Last) darf NICHT den ganzen Lauf killen.\nlet results;\ntry {\n const resp = await new Promise((resolve, reject) => {\n const req = http.request(\n { hostname: 'playwright', port: 3001, path: '/flights-explore', method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 1500000 },\n (res) => { let d = ''; res.on('data', c => d += c); res.on('end', () => {\n if (res.statusCode < 200 || res.statusCode >= 300) return reject(new Error('HTTP ' + res.statusCode + ': ' + d.slice(0, 120)));\n try { resolve(JSON.parse(d)); } catch (e) { reject(new Error('Nicht-JSON-Antwort: ' + d.slice(0, 80).replace(/\\s+/g, ' '))); }\n }); }\n );\n req.on('error', reject);\n req.on('timeout', () => req.destroy(new Error('flights-explore timeout')));\n req.write(body); req.end();\n });\n results = (resp && resp.results) || [];\n} catch (e) {\n // Batch-Fehler abfangen: alle Jobs dieses Batches als failed markieren, Lauf weiterlaufen.\n results = jobs.map(j => ({ id: j.id, dests: {}, count: 0, error: 'batch: ' + e.message }));\n}\nif (results.length) fs.appendFileSync(STATE, results.map(r => JSON.stringify(r)).join('\\n') + '\\n');\n// 10s Politeness-Pause zwischen den Batches (gegen Rate-Limiting der Datacenter-IP).\nawait new Promise(r => setTimeout(r, 10000));\nreturn [{ json: { batch: 'ok', anzahl: results.length } }];\n"
},
"id": "f80233a6-104e-408c-976a-ad03ce76b256",
"name": "Scrape Batch",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
512,
128
]
},
{
"parameters": {
"jsCode": "// Aggregation f\u00fcr Google-Flights \"Explore\". Reine Funktion, WORTGLEICH in den\n// n8n-Code-Node \"Aggregieren\" kopiert (symmetrisch zu jobgen.js / parse-explore.js;\n// das n8n-Geklebe \u2014 $input.all() einsammeln, params via $('Jobs generieren') \u2014 bleibt\n// im Node, die testbare Logik hier). Keine externen Abh\u00e4ngigkeiten.\n//\n// Eingabe : jobResults = [{ id:\"IATA|out|ret\", dests:{stadt:{preis,dauer,stopps,url}}, count, error }]\n// params = { origins, dateFrom, dateTo, minDays, erwachsene }\n// Ausgabe : [{ filename:\"flights_explore_results.txt\", content }, { filename:\"...json\", content }]\n\n// \"2026-10-18\" -> \"18.10.2026\"\nfunction deDate(iso) {\n const m = String(iso).match(/^(\\d{4})-(\\d{2})-(\\d{2})$/);\n return m ? `${m[3]}.${m[2]}.${m[1]}` : String(iso);\n}\n\nfunction aggregate(jobResults, params) {\n params = params || {};\n // stadt -> { \"out|ret\" -> { preis, airport, out, ret, dauer, stopps, url } }\n // Pro (Ziel \u00d7 Datumspaar) das Minimum \u00dcBER die Origins; Datumspaare bleiben erhalten.\n const byCity = {};\n const raw = {}; // id -> { iata, out, ret, dests, count, error }\n let failed = 0;\n\n for (const job of jobResults || []) {\n const [airport, out, ret] = String(job.id).split('|');\n const dests = job.dests || {};\n raw[job.id] = { iata: airport, out, ret, dests, count: job.count || 0, error: job.error || null };\n if (job.error || Object.keys(dests).length === 0) failed++;\n\n for (const [stadt, d] of Object.entries(dests)) {\n if (d == null || typeof d.preis !== 'number') continue;\n const key = out + '|' + ret;\n const slot = (byCity[stadt] = byCity[stadt] || {});\n if (!slot[key] || d.preis < slot[key].preis) {\n slot[key] = { preis: d.preis, airport, out, ret, dauer: d.dauer || null, stopps: d.stopps || null, url: d.url || null };\n }\n }\n }\n\n // Alle (Ziel \u00d7 Datumspaar)-Zeilen flach, global aufsteigend nach Preis sortiert.\n // Optionaler Ziel-Filter (params.ziel): nur St\u00e4dte, deren Name den Filter enth\u00e4lt\n // (case-insensitive, Teilstring); leer/nicht gesetzt = alle Ziele wie bisher.\n const zielFilter = String(params.ziel || '').trim().toLowerCase();\n const rows = [];\n for (const [stadt, slots] of Object.entries(byCity)) {\n if (zielFilter && !stadt.toLowerCase().includes(zielFilter)) continue;\n for (const b of Object.values(slots)) rows.push(Object.assign({ stadt }, b));\n }\n rows.sort((a, b) => a.preis - b.preis || a.stadt.localeCompare(b.stadt) || a.out.localeCompare(b.out));\n\n const total = (jobResults || []).length;\n const zielAnzahl = new Set(rows.map(r => r.stadt)).size;\n const direktNote = (params.nurDirekt === false || params.nurDirekt === 'false') ? '' : ' | nur Direktfl\u00fcge';\n const zielNote = zielFilter ? ` | Ziel-Filter: ${params.ziel}` : '';\n\n // \u2500\u2500 TXT: pro Ziel \u00d7 Datumspaar eine Zeile, global aufsteigend nach Preis \u2500\u2500\u2500\u2500\u2500\n const head = [\n 'Google-Flights Explore \u2014 g\u00fcnstigste Ziele (Preis pro Person, aufsteigend)',\n `Abflugh\u00e4fen: ${params.origins || '?'} | Zeitfenster: ${deDate(params.dateFrom)} \u2013 ${deDate(params.dateTo)} | min. ${params.minDays} Tage | ${params.erwachsene || 1} Erwachsene(r)${direktNote}${zielNote}`,\n `Ziele: ${zielAnzahl} | Datumspaar-Treffer: ${rows.length} | Jobs: ${total} (${failed} fehlgeschlagen / ohne Preis)`,\n ''\n ];\n const body = rows.map(b => {\n const zeile = `${b.preis} \u20ac | ${b.stadt} | ab ${b.airport} | ${deDate(b.out)} \u2013 ${deDate(b.ret)} | ${b.dauer || '?'} | ${b.stopps || '?'}`;\n return b.url ? `${zeile}\\n ${b.url}` : zeile;\n });\n const txt = head.concat(body).join('\\n') + '\\n';\n\n return [\n { filename: 'flights_explore_results_christmas.txt', content: txt },\n { filename: 'flights_explore_results_christmas.json', content: JSON.stringify(raw, null, 2) }\n ];\n}\n\n// \u2500\u2500 n8n-Treiber \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst fs = require('fs');\nconst STATE = '/tmp/flights_explore_state.json';\nlet jobResults = [];\nif (fs.existsSync(STATE)) {\n jobResults = fs.readFileSync(STATE, 'utf8').trim().split('\\n').filter(Boolean).map(l => JSON.parse(l));\n fs.unlinkSync(STATE); // idempotent: ein zweiter Aufruf f\u00e4nde nichts mehr\n}\nconst params = $('Konfig').first().json;\nreturn aggregate(jobResults, params).map(f => ({ json: f }));\n"
},
"id": "9a0151ae-7bb2-4eb8-853e-3a3d8b3e2092",
"name": "Aggregieren",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
512,
-128
]
},
{
"parameters": {
"jsCode": "// Die beiden fertigen Dateien (txt + json) je per http.request an /write-output\n// schicken (server.js schreibt sie nach /home/pwuser/output). http.request statt\n// HTTP-Request-Node, damit kein rohes IncomingMessage-Objekt in die Execution-Daten\n// gelangt (zirkul\u00e4re Struktur -> DB-Save bricht sonst).\nconst http = require('http');\nconst out = [];\nfor (const it of $input.all()) {\n const body = JSON.stringify({ filename: it.json.filename, content: it.json.content });\n const r = await new Promise((resolve, reject) => {\n const req = http.request(\n { hostname: 'playwright', port: 3001, path: '/write-output', method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 30000 },\n (res) => { let d = ''; res.on('data', c => d += c); res.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(e); } }); }\n );\n req.on('error', reject);\n req.write(body); req.end();\n });\n out.push({ json: r });\n}\nreturn out;\n"
},
"id": "b48446dd-3485-496b-a217-33981085cac4",
"name": "Dateien schreiben",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
768,
-128
]
},
{
"parameters": {
"rule": {
"interval": [
{
"triggerAtHour": 3
}
]
}
},
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.3,
"position": [
-320,
-160
],
"id": "5cbe261c-7f2b-4980-b8a3-4ec10e26dd1e",
"name": "Schedule Trigger"
}
],
"connections": {
"Manuell starten": {
"main": [
[
{
"node": "Konfig",
"type": "main",
"index": 0
}
]
]
},
"Konfig": {
"main": [
[
{
"node": "Jobs generieren",
"type": "main",
"index": 0
}
]
]
},
"Jobs generieren": {
"main": [
[
{
"node": "Batches",
"type": "main",
"index": 0
}
]
]
},
"Batches": {
"main": [
[
{
"node": "Aggregieren",
"type": "main",
"index": 0
}
],
[
{
"node": "Scrape Batch",
"type": "main",
"index": 0
}
]
]
},
"Scrape Batch": {
"main": [
[
{
"node": "Batches",
"type": "main",
"index": 0
}
]
]
},
"Aggregieren": {
"main": [
[
{
"node": "Dateien schreiben",
"type": "main",
"index": 0
}
]
]
},
"Schedule Trigger": {
"main": [
[
{
"node": "Konfig",
"type": "main",
"index": 0
}
]
]
}
},
"active": true,
"settings": {
"executionOrder": "v1",
"binaryMode": "separate",
"availableInMCP": false
},
"versionId": "174c882f-1ff2-4d30-82a4-4f396af8dc8a",
"id": "WqsfNHMnSvmXZEXV",
"tags": []
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
Flugsuche Explore (Bulgaria Christmas 2026). Event-driven trigger; 8 nodes.
Source: https://github.com/cupakob/ai-services/blob/b1a4585537909b3bfbc7944454091d3053f9c128/n8n/workflows/flights/flight-search-christmas.json — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
Reagendamiento. Uses executeWorkflowTrigger, redis, n8n-nodes-evolution-api, dataTable. Event-driven trigger; 73 nodes.
Blotato. Uses googleSheets, @blotato/n8n-nodes-blotato. Event-driven trigger; 65 nodes.
This template is a hands-on, practical exam designed to help you master n8n Expressions—the key to accessing and manipulating data in your workflows.
This template is a hands-on, practical exam designed to test your understanding of the fundamental JSON data types. It's the perfect way to solidify your knowledge after learning the basics.
Agendamiento. Uses n8n-nodes-evolution-api, redis, dataTable, executeWorkflowTrigger. Event-driven trigger; 60 nodes.