{
  "name": "Flat search (daily rental sweep)",
  "nodes": [
    {
      "id": "ebe8c4ab-ee24-416f-a8b3-deb7d47f864d",
      "name": "Morning Peak Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -460,
        320
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "days",
              "triggerAtHour": 9,
              "triggerAtMinute": 0
            },
            {
              "field": "days",
              "triggerAtHour": 10,
              "triggerAtMinute": 30
            }
          ]
        }
      },
      "notes": "Runs twice inside the morning window because new listings tend to cluster in the first working hours, so a single overnight run arrives after the good ones are gone. Watch your own sources for a week and move both times to match them.",
      "notesInFlow": true
    },
    {
      "id": "9ca223d2-b649-472f-b0b4-38654895b31a",
      "name": "Settings",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        -240,
        320
      ],
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "01ec22fd-a01a-481e-9d67-899f899ad46e",
              "name": "city",
              "value": "Your City",
              "type": "string"
            },
            {
              "id": "682697af-e12b-41eb-8011-af9bee26252b",
              "name": "maxWarmRent",
              "value": 1500,
              "type": "number"
            },
            {
              "id": "a6f1ae09-1a90-422f-8103-3b25ff1e8a90",
              "name": "minSizeSqm",
              "value": 45,
              "type": "number"
            },
            {
              "id": "164dea96-fdd4-4cb5-8512-5d5b7e780ec8",
              "name": "minRooms",
              "value": 1,
              "type": "number"
            },
            {
              "id": "871c9df7-21c3-4e7d-b769-fb2af8fb5d8c",
              "name": "earliestMoveIn",
              "value": "={{ new Date(Date.now() + 60 * 86400000).toISOString().slice(0, 10) }}",
              "type": "string"
            },
            {
              "id": "045ca524-25b1-4d8b-89a3-6f7c67e58208",
              "name": "minLeaseMonths",
              "value": 12,
              "type": "number"
            },
            {
              "id": "2a3fcff7-e29d-4bc8-a977-f94bb537cb97",
              "name": "excludeRestrictedEligibility",
              "value": false,
              "type": "boolean"
            },
            {
              "id": "2cdd1504-9a60-44b6-b393-51d7e0f38a1c",
              "name": "excludeSharedRoom",
              "value": true,
              "type": "boolean"
            },
            {
              "id": "00dad03c-dec4-4c0a-b9e7-67c4b476f3a7",
              "name": "portals",
              "value": "={{ [ { \"name\": \"portal-1\", \"enabled\": true }, { \"name\": \"portal-2\", \"enabled\": true }, { \"name\": \"portal-3\", \"enabled\": false } ] }}",
              "type": "array"
            },
            {
              "id": "be9e34d5-d6d5-44e7-bdae-b513ed392928",
              "name": "portal1FeedUrl",
              "value": "https://example.com/rss/saved-search-1",
              "type": "string"
            },
            {
              "id": "dc6f4829-a660-466f-b80e-ad3f3d54c79b",
              "name": "portal2FeedUrl",
              "value": "https://example.com/rss/saved-search-2",
              "type": "string"
            },
            {
              "id": "eda79015-bac7-4550-8f6b-0e60f070ae9f",
              "name": "portal3SearchUrl",
              "value": "https://example.com/api/listings?city=YourCity&maxRent=1500",
              "type": "string"
            },
            {
              "id": "8a49abd9-c70a-454c-a41f-cadde7deea6d",
              "name": "sheetId",
              "value": "YOUR_GOOGLE_SHEET_ID",
              "type": "string"
            },
            {
              "id": "e014546c-dfdc-41ea-8733-4d0e9890fbbf",
              "name": "sheetName",
              "value": "Listings",
              "type": "string"
            },
            {
              "id": "6208b08f-d74f-4f0e-9ca4-88feca74b46c",
              "name": "telegramChatId",
              "value": "YOUR_TELEGRAM_CHAT_ID",
              "type": "string"
            },
            {
              "id": "37ecd042-0e78-440a-ae46-74fc934332d8",
              "name": "emailTo",
              "value": "you@example.com",
              "type": "string"
            },
            {
              "id": "a0fc72dd-a485-4f8a-8665-5c81ea474258",
              "name": "emailFrom",
              "value": "flat-alerts@example.com",
              "type": "string"
            },
            {
              "id": "9f5ac4c5-ac20-4995-9643-b961ae210588",
              "name": "maxRememberedUrls",
              "value": 2000,
              "type": "number"
            }
          ]
        },
        "includeOtherFields": false,
        "options": {}
      },
      "notes": "Every knob for this workflow lives here, so retargeting the search never means opening another node. Replace the example.com URLs and the two placeholder IDs before the first run.\n\nReplace every value here before you activate it. The shipped numbers exist so the workflow reads as a working example on import, nothing more. earliestMoveIn is an expression that always resolves to sixty days from the run, so the import has a sane date without carrying a fixed one: swap it for your own date string when you know it.",
      "notesInFlow": true
    },
    {
      "id": "8245d62f-c052-41f7-bcc3-08ecf2377714",
      "name": "Portal 1 Feed (RSS)",
      "type": "n8n-nodes-base.rssFeedRead",
      "typeVersion": 1,
      "position": [
        -20,
        320
      ],
      "parameters": {
        "url": "={{ $('Settings').first().json.portal1FeedUrl }}",
        "options": {}
      },
      "alwaysOutputData": true,
      "onError": "continueRegularOutput",
      "notes": "Point this at the RSS feed of a saved search on a portal you already use, one saved search per feed node, and keep the filters on the portal side loose so the rules below stay the single source of truth.",
      "notesInFlow": true
    },
    {
      "id": "1a4d59e6-7efc-4939-9d5e-cfa0ad074812",
      "name": "Portal 2 Feed (RSS)",
      "type": "n8n-nodes-base.rssFeedRead",
      "typeVersion": 1,
      "position": [
        200,
        320
      ],
      "parameters": {
        "url": "={{ $('Settings').first().json.portal2FeedUrl }}",
        "options": {}
      },
      "executeOnce": true,
      "alwaysOutputData": true,
      "onError": "continueRegularOutput",
      "notes": "Set to execute once because it sits downstream of another fetch node, which would otherwise make it run one time per listing the first feed returned.",
      "notesInFlow": true
    },
    {
      "id": "d77dcb3d-b5b6-43d5-96ad-f202d13f0de7",
      "name": "Portal 3 Search (HTTP)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        420,
        320
      ],
      "parameters": {
        "url": "={{ $('Settings').first().json.portal3SearchUrl }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Accept",
              "value": "application/json"
            },
            {
              "name": "User-Agent",
              "value": "n8n flat-search workflow"
            }
          ]
        },
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        }
      },
      "executeOnce": true,
      "alwaysOutputData": true,
      "onError": "continueRegularOutput",
      "notes": "For a portal with no feed, point this at a public JSON search endpoint you are allowed to call or at your own scraper, and leave it disabled in the portals list until it returns real JSON, since no portal API is assumed here on purpose.",
      "notesInFlow": true
    },
    {
      "id": "adf46233-98a5-4859-bb66-8ac342c4c669",
      "name": "Normalise Listings",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        640,
        320
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Portals disagree on field names and on how numbers are written, so this is the only node\n// that knows about their quirks. Everything after it can assume one shape.\nconst settings = $('Settings').first().json;\n\nconst enabledPortals = new Set(\n  (Array.isArray(settings.portals) ? settings.portals : [])\n    .filter((p) => p && p.enabled !== false)\n    .map((p) => String(p.name))\n);\n\nconst SOURCES = [\n  { node: 'Portal 1 Feed (RSS)', source: 'portal-1' },\n  { node: 'Portal 2 Feed (RSS)', source: 'portal-2' },\n  { node: 'Portal 3 Search (HTTP)', source: 'portal-3' },\n];\n\nconst TEXT_KEYS = ['title', 'contentSnippet', 'content', 'contentEncoded', 'description', 'summary', 'subtitle', 'address', 'location', 'text', 'snippet'];\nconst URL_KEYS = ['link', 'url', 'href', 'permalink', 'detailUrl', 'guid'];\nconst TITLE_KEYS = ['title', 'name', 'headline', 'subject'];\n\nfunction pick(json, keys) {\n  for (const k of keys) {\n    const v = json[k];\n    if (typeof v === 'string' && v.trim()) return v.trim();\n  }\n  return null;\n}\n\nfunction haystack(json) {\n  const parts = [];\n  for (const k of TEXT_KEYS) {\n    if (typeof json[k] === 'string') parts.push(json[k]);\n  }\n  // Strip tags so an HTML description does not hide a number behind markup.\n  return parts.join(' \\n ').replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ');\n}\n\nfunction toNumber(raw) {\n  if (raw === null || raw === undefined) return null;\n  if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null;\n  let s = String(raw).replace(/\\s/g, '');\n  if (!s) return null;\n  // Some locales write 1.234,56 where JSON endpoints write 1234.56, so pick the right\n  // decimal separator before parsing instead of after.\n  if (/,\\d{1,2}$/.test(s)) s = s.replace(/\\./g, '').replace(',', '.');\n  else s = s.replace(/\\.(?=\\d{3}(\\D|$))/g, '').replace(',', '.');\n  const n = parseFloat(s.replace(/[^0-9.]/g, ''));\n  return Number.isFinite(n) ? n : null;\n}\n\nfunction firstGroup(text, re) {\n  const m = re.exec(text);\n  return m ? m[1] : null;\n}\n\nfunction parseRent(json, text) {\n  for (const k of ['warmRent', 'totalRent', 'rentTotal', 'warmmiete', 'gesamtmiete', 'price', 'rent']) {\n    const n = toNumber(json[k]);\n    if (n !== null) return n;\n  }\n  // Prefer an explicitly warm or total figure. A cold rent that fits the budget is how you\n  // end up viewing a flat you cannot actually afford.\n  const warm = firstGroup(text, /(?:warm|gesamt|total|inkl\\.?|incl\\.?)[^0-9]{0,24}([0-9][0-9.,]*)\\s*(?:€|EUR)/i);\n  if (warm) return toNumber(warm);\n  const any = firstGroup(text, /([0-9][0-9.,]*)\\s*(?:€|EUR)/i);\n  return any === null ? null : toNumber(any);\n}\n\nfunction parseSqm(json, text) {\n  for (const k of ['sqm', 'size', 'livingSpace', 'wohnflaeche', 'area']) {\n    const n = toNumber(json[k]);\n    if (n !== null) return n;\n  }\n  const m = firstGroup(text, /([0-9]{1,4}(?:[.,][0-9]{1,2})?)\\s*(?:m²|m2|qm|sqm)/i);\n  return m === null ? null : toNumber(m);\n}\n\nfunction parseRooms(json, text) {\n  for (const k of ['rooms', 'roomCount', 'zimmer', 'numberOfRooms']) {\n    const n = toNumber(json[k]);\n    if (n !== null) return n;\n  }\n  const m = firstGroup(text, /([0-9](?:[.,][0-9])?)\\s*(?:zimmer|zi\\.|rooms?\\b)/i);\n  return m === null ? null : toNumber(m);\n}\n\nfunction toIsoDate(value) {\n  if (typeof value !== 'string' || value.length < 6) return null;\n  const d = new Date(value);\n  return isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10);\n}\n\nfunction parseAvailableFrom(json, text) {\n  for (const k of ['availableFrom', 'available_from', 'availableAt', 'verfuegbarAb', 'freiAb', 'moveInDate', 'startDate']) {\n    const iso = toIsoDate(json[k]);\n    if (iso) return iso;\n  }\n  const isoInText = firstGroup(text, /\\b(\\d{4}-\\d{2}-\\d{2})\\b/);\n  if (isoInText) return isoInText;\n  const de = /\\b(\\d{1,2})\\.(\\d{1,2})\\.(\\d{2,4})\\b/.exec(text);\n  if (de) {\n    const year = de[3].length === 2 ? '20' + de[3] : de[3];\n    return year + '-' + de[2].padStart(2, '0') + '-' + de[1].padStart(2, '0');\n  }\n  const monthYear = /\\b(?:ab|from|per)\\s+(0?[1-9]|1[0-2])[./](\\d{4})\\b/i.exec(text);\n  if (monthYear) return monthYear[2] + '-' + monthYear[1].padStart(2, '0') + '-01';\n  // \"sofort\" is a stated date, namely today. It is not a missing date, and the rules node\n  // will usually reject it for being available too early, which is the correct outcome.\n  if (/(ab sofort|sofort frei|sofort|immediately|available now)/i.test(text)) {\n    return new Date().toISOString().slice(0, 10);\n  }\n  return null;\n}\n\nfunction unwrap(items) {\n  // A JSON search endpoint wraps its results in a field and n8n hands that back as a single\n  // item, so flatten the first array of objects before treating rows as listings.\n  const rows = [];\n  for (const item of items) {\n    const json = item.json || {};\n    const arrayField = Object.keys(json).find(\n      (k) => Array.isArray(json[k]) && json[k].length && typeof json[k][0] === 'object'\n    );\n    if (arrayField) rows.push(...json[arrayField]);\n    else rows.push(json);\n  }\n  return rows;\n}\n\nconst foundAt = new Date().toISOString();\nconst out = [];\n\nfor (const src of SOURCES) {\n  if (!enabledPortals.has(src.source)) continue;\n  let rows = [];\n  try {\n    rows = unwrap($(src.node).all());\n  } catch (e) {\n    // A portal that failed or was disabled must not take the other portals down with it.\n    rows = [];\n  }\n  for (const row of rows) {\n    if (!row || typeof row !== 'object') continue;\n    const text = haystack(row);\n    const url = pick(row, URL_KEYS);\n    const title = pick(row, TITLE_KEYS);\n    if (!url && !title) continue;\n    out.push({\n      json: {\n        title,\n        url,\n        rent: parseRent(row, text),\n        sqm: parseSqm(row, text),\n        rooms: parseRooms(row, text),\n        availableFrom: parseAvailableFrom(row, text),\n        source: src.source,\n        sourceText: text.slice(0, 1200),\n        foundAt,\n      },\n    });\n  }\n}\n\nreturn out;\n"
      },
      "notes": "The only node that knows portal specific field names, so everything after it can assume one shape of title, url, rent, sqm, rooms, availableFrom and source.",
      "notesInFlow": true
    },
    {
      "id": "46585a41-271d-424c-b4be-89ab67c1b5d0",
      "name": "Has Title And Link",
      "type": "n8n-nodes-base.filter",
      "typeVersion": 2.2,
      "position": [
        860,
        320
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "eec8d995-13c3-45b4-9f76-5d151c250870",
              "leftValue": "={{ $json.url }}",
              "rightValue": "",
              "operator": {
                "type": "string",
                "operation": "notEmpty",
                "singleValue": true
              }
            },
            {
              "id": "aad126e8-79da-43a3-994a-7fa74c374612",
              "leftValue": "={{ $json.title }}",
              "rightValue": "",
              "operator": {
                "type": "string",
                "operation": "notEmpty",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "notes": "A row with no title or no link cannot be judged or opened later, so it is dropped here rather than filling the audit log with rows nobody can act on.",
      "notesInFlow": true
    },
    {
      "id": "afe4963e-4857-4070-8ed4-6291c1f45f07",
      "name": "Apply Rejection Rules",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1080,
        320
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Rules run top to bottom and the first hit wins, so this order is also the order in which\n// a rejection gets explained in the sheet. Reorder it and the audit log changes meaning.\nconst s = $('Settings').first().json;\n\nconst maxWarmRent = Number(s.maxWarmRent);\nconst minSizeSqm = Number(s.minSizeSqm);\nconst minRooms = Number(s.minRooms);\nconst minLeaseMonths = Number(s.minLeaseMonths);\nconst earliestMoveIn = String(s.earliestMoveIn || '');\n\n// The word boundary matters: some markets negate a fixed term word with a prefix, so\n// \"unbefristet\" is the opposite of \"befristet\", and without the boundary every open ended\n// lease would be rejected as fixed term. Add the words your own market uses.\nconst FIXED_TERM = /\\bbefristet|zeitmietvertrag|zwischenmiete|untermiete|sublet|short[\\s-]?term|temporar|limited period|fixed[\\s-]?term|auf zeit/i;\nconst SHARED_ROOM = /\\bwg\\b|wohngemeinschaft|mitbewohner|shared (?:room|flat|apartment|house)|flatshare|room in a shared/i;\n// Some markets reserve part of the stock for tenants who hold a means tested eligibility\n// certificate. Off by default, since it only helps if you do not hold one. Put your own\n// market's name for that certificate in this list before switching it on.\nconst RESTRICTED_ELIGIBILITY = /eligibility certificate|income certificate|housing certificate|\\bwbs\\b|wohnberechtigungsschein/i;\n\nfunction statedLeaseMonths(text) {\n  const m = /(\\d{1,2})\\s*(?:monate|monaten|months?)\\b/i.exec(text);\n  return m ? Number(m[1]) : null;\n}\n\nconst out = [];\n\nfor (const item of $input.all()) {\n  const l = item.json;\n  const text = String(l.title || '') + ' ' + String(l.sourceText || '');\n  const leaseMonths = statedLeaseMonths(text);\n  let skipReason = '';\n\n  if (!l.url || !l.title) {\n    skipReason = 'Skipped-Unparsable';\n  } else if (l.rent === null || l.rent === undefined) {\n    // A rent that is not stated means \"on request\", and on request means over budget often\n    // enough that treating it as unknown wastes an afternoon.\n    skipReason = 'Skipped-NoStatedRent';\n  } else if (l.rent > maxWarmRent) {\n    skipReason = 'Skipped-OverBudget';\n  } else if (l.sqm !== null && l.sqm !== undefined && l.sqm < minSizeSqm) {\n    // Size and room count are allowed to be absent, unlike rent and date, because plenty of\n    // honest listings put them in a photo instead of the text.\n    skipReason = 'Skipped-TooSmall';\n  } else if (l.rooms !== null && l.rooms !== undefined && l.rooms < minRooms) {\n    skipReason = 'Skipped-TooFewRooms';\n  } else if (FIXED_TERM.test(text) || (leaseMonths !== null && leaseMonths < minLeaseMonths)) {\n    skipReason = 'Skipped-FixedTerm';\n  } else if (s.excludeSharedRoom && SHARED_ROOM.test(text)) {\n    skipReason = 'Skipped-SharedRoom';\n  } else if (s.excludeRestrictedEligibility && RESTRICTED_ELIGIBILITY.test(text)) {\n    skipReason = 'Skipped-RestrictedEligibility';\n  } else if (!l.availableFrom) {\n    // The one rule people get wrong: a listing with no availability date reads as flexible\n    // and never is, so an absent date is a skip and not a pass.\n    skipReason = 'Skipped-NoStatedDate';\n  } else if (earliestMoveIn && l.availableFrom < earliestMoveIn) {\n    // ISO dates compare correctly as plain strings, so no date library is needed here.\n    skipReason = 'Skipped-AvailableTooEarly';\n  }\n\n  out.push({\n    json: Object.assign({}, l, {\n      skipReason,\n      status: skipReason ? 'Skipped' : 'Match',\n    }),\n  });\n}\n\nreturn out;\n"
      },
      "notes": "Rules are checked top to bottom and the first hit wins, so the order is also the reporting priority, and a listing with no stated availability date is skipped rather than passed.",
      "notesInFlow": true
    },
    {
      "id": "4fa1b376-3d84-4752-92d7-dfebf616644e",
      "name": "Load Seen Listings",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        1300,
        320
      ],
      "parameters": {
        "documentId": {
          "__rl": true,
          "value": "={{ $('Settings').first().json.sheetId }}",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "={{ $('Settings').first().json.sheetName }}",
          "mode": "name"
        },
        "options": {}
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "Google Sheets account"
        }
      },
      "executeOnce": true,
      "alwaysOutputData": true,
      "onError": "continueRegularOutput",
      "notes": "Reads back the URLs already written so a listing is announced once, and it continues on error so a missing sheet degrades to the static data fallback instead of killing the run.",
      "notesInFlow": true
    },
    {
      "id": "f5c13b22-80dd-4fde-8837-fd2ff26d77e7",
      "name": "Dedupe Against History",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1520,
        320
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Two memories on purpose. The sheet is the real one and survives a reinstall, the static\n// data keeps the workflow honest on the days the sheet or its credential is unavailable.\nconst s = $('Settings').first().json;\nconst limit = Number(s.maxRememberedUrls) || 2000;\n\nfunction key(url) {\n  if (!url) return '';\n  // Portals bolt tracking parameters onto links, so the same flat arrives under many URLs.\n  return String(url).split('#')[0].split('?')[0].replace(/\\/+$/, '').toLowerCase();\n}\n\nconst sheetKeys = [];\n\nfor (const row of $input.all()) {\n  const json = row.json || {};\n  for (const k of ['url', 'URL', 'Url', 'link', 'Link']) {\n    if (json[k]) {\n      sheetKeys.push(key(json[k]));\n      break;\n    }\n  }\n}\n\nconst staticData = $getWorkflowStaticData('global');\nconst remembered = Array.isArray(staticData.seenUrls) ? staticData.seenUrls : [];\nconst seen = new Set(remembered.concat(sheetKeys));\n\nconst fresh = [];\nconst batch = new Set();\n\nfor (const item of $('Apply Rejection Rules').all()) {\n  const k = key(item.json.url);\n  if (!k || seen.has(k) || batch.has(k)) continue;\n  batch.add(k);\n  fresh.push({ json: Object.assign({}, item.json, { dedupeKey: k }) });\n}\n\n// Mirror what the sheet knows into static data, so a day when the sheet cannot be read does\n// not resurrect listings you were already shown. The cap keeps static data from growing for\n// as long as the search runs, since every execution has to carry it.\nstaticData.seenUrls = Array.from(new Set(remembered.concat(sheetKeys, Array.from(batch)))).slice(-limit);\n\nreturn fresh;\n"
      },
      "notes": "Compares against the sheet first and falls back to n8n static data, strips tracking parameters so one flat is one entry, and caps the remembered list so the workflow data cannot grow forever.",
      "notesInFlow": true
    },
    {
      "id": "4f6adadc-b621-4ed9-bcf3-54853be8b40c",
      "name": "Append To Sheet",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        1760,
        180
      ],
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "value": "={{ $('Settings').first().json.sheetId }}",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "={{ $('Settings').first().json.sheetName }}",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "foundAt": "={{ $json.foundAt }}",
            "source": "={{ $json.source }}",
            "title": "={{ $json.title }}",
            "url": "={{ $json.url }}",
            "rent": "={{ $json.rent }}",
            "sqm": "={{ $json.sqm }}",
            "rooms": "={{ $json.rooms }}",
            "availableFrom": "={{ $json.availableFrom }}",
            "status": "={{ $json.status }}",
            "skipReason": "={{ $json.skipReason }}"
          },
          "matchingColumns": [],
          "schema": []
        },
        "options": {}
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "Google Sheets account"
        }
      },
      "onError": "continueRegularOutput",
      "notes": "Rejected listings are written too, with their reason in the skipReason column, because a filter you cannot audit is a filter you quietly stop trusting. Create the sheet with these ten headers in row one.",
      "notesInFlow": true
    },
    {
      "id": "86fa2665-02d6-4569-905f-65a9b2180f6b",
      "name": "Is Match?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1760,
        440
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "cd473e00-ee1e-44ca-8d66-c75eced40abe",
              "leftValue": "={{ $json.skipReason }}",
              "rightValue": "",
              "operator": {
                "type": "string",
                "operation": "empty",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "notes": "An empty skipReason means the listing survived every rule, and only that branch is worth an interruption. It runs in parallel with the sheet append so a sheet problem cannot swallow an alert.",
      "notesInFlow": true
    },
    {
      "id": "7cf484c8-e5b4-410f-a406-04e5c01b30de",
      "name": "Notify On Telegram",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        1980,
        360
      ],
      "parameters": {
        "chatId": "={{ $('Settings').first().json.telegramChatId }}",
        "text": "=New flat match\n\n{{ $json.title }}\n{{ $json.rent }} all in, {{ $json.sqm }} m2, {{ $json.rooms }} rooms\nAvailable from {{ $json.availableFrom }}\nSource: {{ $json.source }}\n{{ $json.url }}",
        "additionalFields": {
          "appendAttribution": false,
          "disable_web_page_preview": false
        }
      },
      "credentials": {
        "telegramApi": {
          "name": "Telegram account"
        }
      },
      "onError": "continueRegularOutput",
      "notes": "Fires once per matching listing, which is the point on a good morning and unbearable on a bad one, so tighten the numbers in Settings rather than muting this chat.",
      "notesInFlow": true
    },
    {
      "id": "e25e6953-224a-4596-bf25-4a655ded5296",
      "name": "Notify By Email",
      "type": "n8n-nodes-base.emailSend",
      "typeVersion": 2.1,
      "position": [
        1980,
        560
      ],
      "parameters": {
        "fromEmail": "={{ $('Settings').first().json.emailFrom }}",
        "toEmail": "={{ $('Settings').first().json.emailTo }}",
        "subject": "=Flat match: {{ $json.title }}",
        "emailFormat": "html",
        "html": "=<p><strong>{{ $json.title }}</strong></p><p>{{ $json.rent }} all in, {{ $json.sqm }} m2, {{ $json.rooms }} rooms<br>Available from {{ $json.availableFrom }}<br>Source: {{ $json.source }}</p><p><a href=\"{{ $json.url }}\">Open the listing</a></p>",
        "options": {}
      },
      "credentials": {
        "smtp": {
          "name": "SMTP account"
        }
      },
      "onError": "continueRegularOutput",
      "notes": "A second channel because push notifications get muted, and because an inbox keeps a searchable record of what was sent on which morning.",
      "notesInFlow": true
    },
    {
      "id": "bdd306d9-9b32-428e-acb2-deae44f50ea8",
      "name": "Logged As Skipped",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1980,
        760
      ],
      "parameters": {},
      "notes": "Rejected listings stop here on purpose, since they are already in the sheet with the rule that rejected them, which is where you go when the filter feels too aggressive.",
      "notesInFlow": true
    }
  ],
  "connections": {
    "Morning Peak Trigger": {
      "main": [
        [
          {
            "node": "Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Settings": {
      "main": [
        [
          {
            "node": "Portal 1 Feed (RSS)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Portal 1 Feed (RSS)": {
      "main": [
        [
          {
            "node": "Portal 2 Feed (RSS)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Portal 2 Feed (RSS)": {
      "main": [
        [
          {
            "node": "Portal 3 Search (HTTP)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Portal 3 Search (HTTP)": {
      "main": [
        [
          {
            "node": "Normalise Listings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalise Listings": {
      "main": [
        [
          {
            "node": "Has Title And Link",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has Title And Link": {
      "main": [
        [
          {
            "node": "Apply Rejection Rules",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Apply Rejection Rules": {
      "main": [
        [
          {
            "node": "Load Seen Listings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Seen Listings": {
      "main": [
        [
          {
            "node": "Dedupe Against History",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Dedupe Against History": {
      "main": [
        [
          {
            "node": "Append To Sheet",
            "type": "main",
            "index": 0
          },
          {
            "node": "Is Match?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Is Match?": {
      "main": [
        [
          {
            "node": "Notify On Telegram",
            "type": "main",
            "index": 0
          },
          {
            "node": "Notify By Email",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Logged As Skipped",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "saveManualExecutions": true,
    "saveDataErrorExecution": "all",
    "saveDataSuccessExecution": "all",
    "timezone": "UTC"
  },
  "meta": {
    "templateCredsSetupCompleted": false,
    "description": "Polls rental portals on a schedule, normalises every source to one shape, rejects listings that fail your rules, remembers what it already showed you, and logs the rejections with their reason. Edit the Settings node and nothing else."
  }
}
