{
  "name": "Flugsuche Explore (Deal-Finder)",
  "nodes": [
    {
      "parameters": {},
      "id": "7ef3aed8-a7ef-59b8-8f90-336b8a2f102b",
      "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-10-17",
              "type": "string"
            },
            {
              "id": "cfg-to",
              "name": "dateTo",
              "value": "2026-11-01",
              "type": "string"
            },
            {
              "id": "cfg-mindays",
              "name": "minDays",
              "value": 6,
              "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": "c31d381e-0aea-586c-95e6-8d0fb9e1a60b",
      "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": "76ac67ce-6861-5e6e-be69-8e84ff92708b",
      "name": "Jobs generieren",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        64,
        0
      ]
    },
    {
      "parameters": {
        "batchSize": 5,
        "options": {}
      },
      "id": "8ffb7e86-39ff-5147-9013-43c962e10344",
      "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": "35c1b79c-4c20-5adf-8f3d-b72520cf8263",
      "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.txt', content: txt },\n    { filename: 'flights_explore_results.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": "09a969be-a7b3-532b-9c07-0d054352b360",
      "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": "8c5d1c6a-932e-5a6e-8a7a-86585d313ea4",
      "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": "7510b83f-d719-4799-9619-9c9a852fbcff",
      "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"
  },
  "versionId": "5b682a89-9aeb-408f-93af-3805fb01b35e",
  "id": "5d9129782c3d495b",
  "tags": []
}