{
  "name": "Screen new Zillow listings for rental yield and price cuts with Apify, Google Sheets and Gmail",
  "nodes": [
    {
      "parameters": {
        "content": "## Screen new Zillow listings for rental yield\n\n### How it works\nEach morning this workflow pulls the newest for-sale listings in your area with the [Zillow For-Sale, Rent & Sold Listing Extractor](https://apify.com/nimbryxo-studio/zillow-for-sale-rent-and-sold-listing-extractor) Actor on Apify and runs the same rental math on each one: a year of rent, minus tax, HOA, insurance and a reserve for vacancy, repairs and management, divided by the price. Rent is Zillow's rent estimate when the listing shows one (most don't), otherwise your own rent comps by bedroom count.\n\nYou get one email with listings that are new to you and clear your net-yield bar, plus price cuts that now clear it. Every listing and its numbers go into Google Sheets. Days with nothing new send nothing.\n\nIt's for buy-and-hold investors and agents who scan a market every day. The math is a first filter, not an appraisal.\n\n### Setup\n1. In Apify Console > Settings > API & Integrations, copy your token. In n8n, create a **Header Auth** credential (name `Authorization`, value `Bearer <token>`) and select it in the three Apify nodes.\n2. Add a Google Sheets tab named `Listings` with this header row: `key, address, price, rentEstimate, hoaMonthly, taxAnnual, netYieldPct, grossYieldPct, passes, note, beds, baths, sqft, status, firstSeen, lastSeen, url`\n3. Connect Google Sheets and Gmail.\n4. Fill in **Config**: area, price cap, bedrooms, yield bar, `rentByBedrooms` from your own comps, cost assumptions, sheet ID and email.\n\n### Customization\nTune the reserve and insurance percentages to your market or raise `maxListings` for busy areas. Each run is billed to your Apify account; `maxCostUsd` caps it.",
        "height": 864,
        "width": 560
      },
      "id": "af1400f4-6dc6-41dc-a265-1b8f836a5c38",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -660,
        -356
      ]
    },
    {
      "parameters": {
        "content": "## 1. Settings and last state\nChecks Config before anything costs money, then reads every listing you already track with its last price.",
        "height": 388,
        "width": 940,
        "color": 7
      },
      "id": "d2931ff7-1460-465b-a153-e592e8361eb5",
      "name": "Section: settings",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -40,
        -188
      ]
    },
    {
      "parameters": {
        "content": "## 2. Run the Actor and wait\nStarts the Apify run and checks its status every minute until it ends. A failed or stuck run goes to the alert below.",
        "height": 828,
        "width": 460,
        "color": 7
      },
      "id": "f5549ae1-8bb7-4be5-acb2-df8792ede0f8",
      "name": "Section: run",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        920,
        -188
      ]
    },
    {
      "parameters": {
        "content": "## 3. Underwrite and compare\nScores each listing on net yield with your cost assumptions, then keeps new listings and price cuts that clear your bar.",
        "height": 388,
        "width": 460,
        "color": 7
      },
      "id": "dfcbd7ff-fc4c-48d3-a6ad-24531abd9b7b",
      "name": "Section: compare",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1400,
        -188
      ]
    },
    {
      "parameters": {
        "content": "## 4. Save and email\nUpdates one row per listing (matched on `key`) and emails only when there is something new.",
        "height": 828,
        "width": 460,
        "color": 7
      },
      "id": "3d9f43d3-e4e1-44bc-ae92-a6dadf7881a8",
      "name": "Section: deliver",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1880,
        -188
      ]
    },
    {
      "parameters": {
        "content": "## If the run fails\nEmails the run status and a console link, then stops with an error so the execution shows as failed.",
        "height": 366,
        "width": 460,
        "color": 7
      },
      "id": "ab4f4d58-f478-461e-a151-ce5bcfe8fa45",
      "name": "Section: failure",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1400,
        274
      ]
    },
    {
      "parameters": {
        "content": "## Paid run\nStarts a run on your Apify account. `maxCostUsd` in Config caps what one run can spend.",
        "height": 148,
        "width": 300,
        "color": 3
      },
      "id": "35c5d302-650b-47eb-a2d9-30c0026f1175",
      "name": "Warning: paid run",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        860,
        -356
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtHour": 6,
              "triggerAtMinute": 30
            }
          ]
        }
      },
      "id": "776949ee-6621-46cf-a284-fb4bd38307ed",
      "name": "Every day at 06:30",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        0
      ]
    },
    {
      "parameters": {
        "mode": "raw",
        "jsonOutput": "{\n  \"search\": \"Cleveland, OH\",\n  \"priceMax\": 250000,\n  \"bedsMin\": \"2\",\n  \"maxListings\": 40,\n  \"minNetYieldPct\": 5,\n  \"rentByBedrooms\": {\n    \"1\": 850,\n    \"2\": 1100,\n    \"3\": 1350,\n    \"4\": 1550\n  },\n  \"skipPropertyTypes\": [\n    \"MANUFACTURED\",\n    \"LOT\"\n  ],\n  \"vacancyAndRepairsPctOfRent\": 15,\n  \"managementPctOfRent\": 8,\n  \"insurancePctOfPrice\": 0.5,\n  \"fallbackTaxRatePct\": 1,\n  \"emailTo\": \"you@example.com\",\n  \"googleSheetId\": \"YOUR_GOOGLE_SHEET_ID\",\n  \"sheetTab\": \"Listings\",\n  \"maxCostUsd\": 1,\n  \"maxWaitMinutes\": 20\n}\n",
        "options": {}
      },
      "id": "e2c8f41e-707e-4f30-afc3-c3c2a9a9c99a",
      "name": "Config",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        240,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Checks Config before any paid Apify run and builds the Actor input.\nconst cfg = $('Config').first().json;\nconst problems = [];\nconst search = String(cfg.search || '').trim();\nif (!search) problems.push('set \"search\" to a city, ZIP code or neighborhood, for example \"Las Vegas, NV\"');\nconst maxListings = Number(cfg.maxListings);\nif (!Number.isInteger(maxListings) || maxListings < 1 || maxListings > 500) problems.push('\"maxListings\" must be a whole number from 1 to 500');\nconst minYield = Number(cfg.minNetYieldPct);\nif (!Number.isFinite(minYield)) problems.push('\"minNetYieldPct\" must be a number, for example 5');\nconst beds = String(cfg.bedsMin ?? 'any');\nif (!['any', '1', '2', '3', '4', '5'].includes(beds)) problems.push('\"bedsMin\" must be \"any\" or \"1\" to \"5\"');\nif (!cfg.googleSheetId || /YOUR_/i.test(String(cfg.googleSheetId))) problems.push('set \"googleSheetId\" to the ID in your sheet URL');\nif (!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(String(cfg.emailTo || '')) || /example\\.com$/i.test(String(cfg.emailTo))) problems.push('set \"emailTo\" to your email address');\nif (problems.length) throw new Error(`Config: ${problems.join('; ')}`);\n\nconst actorInput = {\n  search,\n  mode: 'BUY',\n  maxItems: maxListings,\n  sortBy: 'newest',\n  bedsMin: beds,\n  scrapeDetails: true, // rent Zestimate, HOA and tax come from the detail page\n};\nif (Number(cfg.priceMax) > 0) actorInput.priceMax = Math.round(Number(cfg.priceMax));\nif (Number(cfg.priceMin) > 0) actorInput.priceMin = Math.round(Number(cfg.priceMin));\nreturn [{ json: { actorInput } }];\n"
      },
      "id": "4bd83dda-2932-472f-ab26-57b7713af1ac",
      "name": "Check settings",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        480,
        0
      ]
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Config').first().json.googleSheetId }}"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $('Config').first().json.sheetTab }}"
        },
        "options": {}
      },
      "id": "280e490c-8edc-4697-ad8a-bf793044ea65",
      "name": "Read previous run from Google Sheets",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        720,
        0
      ],
      "executeOnce": true,
      "alwaysOutputData": true,
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.apify.com/v2/acts/nimbryxo-studio~zillow-for-sale-rent-and-sold-listing-extractor/runs",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "waitForFinish",
              "value": "60"
            },
            {
              "name": "maxTotalChargeUsd",
              "value": "={{ $('Config').first().json.maxCostUsd }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($('Check settings').first().json.actorInput) }}",
        "options": {
          "timeout": 90000
        }
      },
      "id": "d1637a10-1fa8-4de0-a4e5-89aa4261600b",
      "name": "Start Apify run",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        960,
        0
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "succeeded",
                    "leftValue": "={{ $json.data.status }}",
                    "rightValue": "SUCCEEDED",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Succeeded"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "running",
                    "leftValue": "={{ ['READY', 'RUNNING'].includes($json.data.status) && $runIndex < $('Config').first().json.maxWaitMinutes }}",
                    "rightValue": "",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Still running"
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra",
          "renameFallbackOutput": "Failed or stuck"
        }
      },
      "id": "ef59846d-e8e7-4091-a500-80278c1c165c",
      "name": "Route by run status",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        1200,
        0
      ]
    },
    {
      "parameters": {
        "url": "={{ 'https://api.apify.com/v2/actor-runs/' + $json.data.id }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "waitForFinish",
              "value": "60"
            }
          ]
        },
        "options": {
          "timeout": 90000
        }
      },
      "id": "6fbdd3ae-ec26-413f-a58b-ae66d5330dab",
      "name": "Check run status",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1200,
        440
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "url": "={{ 'https://api.apify.com/v2/datasets/' + $json.data.defaultDatasetId + '/items' }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "clean",
              "value": "true"
            },
            {
              "name": "format",
              "value": "json"
            }
          ]
        },
        "options": {
          "timeout": 120000
        }
      },
      "id": "0b9a6df4-a418-487a-ab88-580a253e7b73",
      "name": "Download results",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1440,
        0
      ],
      "alwaysOutputData": true,
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "jsCode": "// Underwrites every listing with the same simple rental math, then keeps only what is worth a look today:\n// new listings that clear your net-yield bar and price cuts on listings you already track that clear it now.\n// Returns ONE item: { rows } for the sheet and { report, subject, html } for the email.\nconst cfg = $('Config').first().json;\nconst today = new Date().toISOString().slice(0, 10);\nconst num = (v) => {\n  if (v === null || v === undefined || v === '') return null;\n  const n = Number(String(v).replace(/[^0-9.-]/g, ''));\n  return Number.isFinite(n) ? n : null;\n};\nconst esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;');\nconst usd = (v) => (v === null ? 'n/a' : `$${Math.round(v).toLocaleString('en-US')}`);\nconst pct = (v) => (v === null ? 'n/a' : `${v.toFixed(1)}%`);\nconst round1 = (v) => (v === null ? '' : Math.round(v * 10) / 10);\n\nconst minYield = num(cfg.minNetYieldPct) ?? 5;\nconst reservePct = (num(cfg.vacancyAndRepairsPctOfRent) ?? 15) + (num(cfg.managementPctOfRent) ?? 8);\nconst insurancePct = num(cfg.insurancePctOfPrice) ?? 0.5;\nconst fallbackTaxPct = num(cfg.fallbackTaxRatePct) ?? 1;\n\n// Rent: Zillow's rent Zestimate when the listing has one (most for-sale listings don't), else your own rent comps by\n// bedroom count (rentByBedrooms; the largest size at or below the listing's bedrooms).\nconst rentTable = Object.entries(cfg.rentByBedrooms || {}).map(([b, v]) => [Number(b), num(v)]).filter(([b, v]) => Number.isFinite(b) && v).sort((a, b) => a[0] - b[0]);\nfunction rentFor(r) {\n  const z = num(r.rentZestimate);\n  if (z) return { rent: z, source: 'Zillow' };\n  const beds = num(r.beds);\n  const row = beds === null ? null : [...rentTable].reverse().find(([b]) => b <= beds);\n  return row ? { rent: row[1], source: 'your table' } : { rent: null, source: '' };\n}\n// Property types left out of the screen (manufactured homes usually sit on rented land whose lot rent is not in the\n// listing's HOA, so their yield would look far better than it is).\nconst skipTypes = (cfg.skipPropertyTypes || []).map((x) => String(x).toUpperCase());\nfunction underwrite(r) {\n  if (skipTypes.includes(String(r.propertyType || '').toUpperCase())) return { price: num(r.price), rent: null, net: null, gross: null, note: `skipped: ${String(r.propertyType).toLowerCase().replace(/_/g, ' ')}`, source: '' };\n  const price = num(r.price);\n  const { rent, source } = rentFor(r);\n  if (!price || !rent) return { price, rent, net: null, gross: null, note: rent ? 'no price' : 'no rent estimate', source };\n  const annualRent = rent * 12;\n  let tax = num(r.taxAnnualAmount);\n  let note = '';\n  if (tax === null && num(r.propertyTaxRate) !== null) tax = price * num(r.propertyTaxRate) / 100; // the rate is a percent\n  if (tax === null) { tax = price * fallbackTaxPct / 100; note = 'tax estimated'; }\n  if (source === 'your table') note = note ? `${note}, rent from your table` : 'rent from your table';\n  const hoa = (num(r.hoaFee) ?? 0) * 12;\n  const costs = tax + hoa + price * insurancePct / 100 + annualRent * reservePct / 100;\n  return { price, rent, tax, net: (annualRent - costs) / price * 100, gross: annualRent / price * 100, note, source };\n}\n\nconst saved = new Map();\nfor (const item of $('Read previous run from Google Sheets').all()) {\n  const r = item.json || {};\n  if (r.key) saved.set(String(r.key), r);\n}\nconst listings = $input.all().map((i) => i.json || {}).filter((r) => r.zpid || r.url);\n\nconst rows = [];\nconst hits = [];\nlet noRent = 0;\nlet skipped = 0;\nfor (const r of listings) {\n  const key = String(r.zpid || r.url);\n  const u = underwrite(r);\n  if (u.note.startsWith('skipped')) skipped += 1;\n  else if (u.net === null) noRent += 1;\n  const before = saved.get(key);\n  const passes = u.net !== null && u.net >= minYield;\n  let why = '';\n  if (!before) {\n    if (passes) why = 'New listing';\n  } else {\n    const was = num(before.price);\n    if (passes && was && u.price && u.price < was) why = `Price cut ${usd(was - u.price)} (${pct((was - u.price) / was * 100)})`;\n  }\n  const row = {\n    key,\n    address: [r.address, r.city, r.state, r.zipCode].filter(Boolean).join(', '),\n    price: u.price ?? '',\n    rentEstimate: u.rent ?? '',\n    hoaMonthly: num(r.hoaFee) ?? '',\n    taxAnnual: u.tax === undefined || u.tax === null ? '' : Math.round(u.tax),\n    netYieldPct: round1(u.net),\n    grossYieldPct: round1(u.gross),\n    passes: passes ? 'yes' : 'no',\n    note: u.note,\n    beds: r.beds ?? '',\n    baths: r.baths ?? '',\n    sqft: r.sqft ?? '',\n    status: r.status || '',\n    firstSeen: (before && before.firstSeen) || today,\n    lastSeen: today,\n    url: r.url || '',\n  };\n  rows.push(row);\n  if (why) hits.push({ row, why, net: u.net });\n}\nhits.sort((a, b) => b.net - a.net);\n\nconst firstRun = saved.size === 0;\nconst where = esc(cfg.search);\nconst assumptions = `Net yield = (12 x monthly rent - property tax - HOA - insurance ${insurancePct}% of price - ${reservePct}% of rent for vacancy, repairs and management) / price. Rent is Zillow's rent Zestimate when the listing has one, else your rentByBedrooms table. Tax comes from the listing; when it is missing, ${fallbackTaxPct}% of price is used.`;\nlet subject = '';\nlet html = '';\nif (listings.length === 0) {\n  subject = `Zillow yield screen: no listings returned for ${cfg.search}`;\n  html = `<p>The Zillow search for <b>${where}</b> returned no listings today. Check <code>search</code>, <code>priceMax</code> and <code>bedsMin</code> in Config.</p>`;\n} else if (hits.length) {\n  subject = `${hits.length} Zillow listing${hits.length === 1 ? ' clears' : 's clear'} your ${minYield}% net yield bar in ${cfg.search}`;\n  const tr = hits.map(({ row, why }) => `<tr><td><a href=\"${esc(row.url)}\">${esc(row.address)}</a></td><td>${usd(num(row.price))}</td><td>${usd(num(row.rentEstimate))}/mo${row.note.includes('your table') ? '*' : ''}</td><td><b>${pct(num(row.netYieldPct))}</b></td><td>${pct(num(row.grossYieldPct))}</td><td>${esc(row.beds)} bd / ${esc(row.baths)} ba</td><td>${esc(why)}${row.note ? ` (${esc(row.note)})` : ''}</td></tr>`).join('');\n  html = `<p>${firstRun ? `First run: ${listings.length} listings saved as a baseline. These already clear your bar:` : `Checked ${listings.length} listings in <b>${where}</b>.`}</p>`\n    + `<table border=\"1\" cellpadding=\"6\" cellspacing=\"0\" style=\"border-collapse:collapse;font-family:Arial,sans-serif;font-size:13px\"><tr><th>Address</th><th>Price</th><th>Rent</th><th>Net yield</th><th>Gross yield</th><th>Size</th><th>Why it's here</th></tr>${tr}</table>`;\n} else if (firstRun) {\n  subject = `Zillow yield screen is set up for ${cfg.search}`;\n  html = `<p>First run: ${listings.length} listings saved as a baseline. None clears your ${minYield}% net yield bar yet; you'll get an email when a new listing or a price cut does.</p>`;\n}\nif (html) {\n  if (hits.some((h) => h.row.note.includes('your table'))) html += '<p style=\"font-size:12px\">* rent from your rentByBedrooms table (Zillow shows no rent estimate for that listing).</p>';\n  html += `<p style=\"color:#666;font-size:12px\">${esc(assumptions)}${noRent ? ` ${noRent} listing${noRent === 1 ? ' has' : 's have'} no rent figure (no Zestimate, no matching row in your table) and ${noRent === 1 ? 'was' : 'were'} not scored.` : ''}${skipped ? ` ${skipped} skipped by type (skipPropertyTypes).` : ''} Estimates only: check rents, taxes and insurance before you offer.</p>`;\n}\nreturn [{ json: { report: Boolean(html), subject, html, rows, hits: hits.length, checked: listings.length } }];\n"
      },
      "id": "47f0fad9-25a3-48b2-a9f3-4ff65cf56099",
      "name": "Underwrite listings and pick the worthwhile ones",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1680,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// One item per sheet row (none on a day without rows, so the sheet step is skipped).\nreturn ($json.rows || []).map((row) => ({ json: row }));"
      },
      "id": "3050e4e3-23e8-4dcb-ac51-ed8d61837256",
      "name": "Rows for the sheet",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1920,
        0
      ]
    },
    {
      "parameters": {
        "operation": "appendOrUpdate",
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Config').first().json.googleSheetId }}"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $('Config').first().json.sheetTab }}"
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "value": {},
          "matchingColumns": [
            "key"
          ],
          "schema": []
        },
        "options": {
          "cellFormat": "RAW"
        }
      },
      "id": "8123522f-f272-4fdf-ad29-eaf56fd96b64",
      "name": "Save to Google Sheets",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        2160,
        0
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "report",
              "leftValue": "={{ $json.report }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "6cdc9871-ae99-4de1-a482-230e7a1fe957",
      "name": "Anything to report?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1920,
        440
      ]
    },
    {
      "parameters": {
        "sendTo": "={{ $('Config').first().json.emailTo }}",
        "subject": "={{ $json.subject }}",
        "emailType": "html",
        "message": "={{ $json.html }}",
        "options": {
          "appendAttribution": false
        }
      },
      "id": "51dfff7e-f10e-4e0b-a55d-497378ac0355",
      "name": "Email the report",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        2160,
        440
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 3000
    },
    {
      "parameters": {
        "sendTo": "={{ $('Config').first().json.emailTo }}",
        "subject": "={{ 'Apify run ' + $json.data.status + ': Screen new Zillow listings for rental yield and price cuts' }}",
        "emailType": "html",
        "message": "={{ '<p>The Apify run ended as <b>' + $json.data.status + '</b>' + (['READY','RUNNING'].includes($json.data.status) ? ' (still running after ' + $('Config').first().json.maxWaitMinutes + ' minutes)' : '') + '.</p><p><a href=\"https://console.apify.com/view/runs/' + $json.data.id + '\">Open the run in Apify Console</a> to see its log.</p>' }}",
        "options": {
          "appendAttribution": false
        }
      },
      "id": "61351e88-d633-41c4-a776-dd94a8779fb3",
      "name": "Alert: run failed",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        1440,
        440
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 3000
    },
    {
      "parameters": {
        "errorMessage": "={{ 'Apify run ' + $('Route by run status').item.json.data.status + ': https://console.apify.com/view/runs/' + $('Route by run status').item.json.data.id }}"
      },
      "id": "c704d032-d8d6-4d00-aeec-1a82dde15d49",
      "name": "Stop: run failed",
      "type": "n8n-nodes-base.stopAndError",
      "typeVersion": 1,
      "position": [
        1680,
        440
      ]
    }
  ],
  "connections": {
    "Every day at 06:30": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config": {
      "main": [
        [
          {
            "node": "Check settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check settings": {
      "main": [
        [
          {
            "node": "Read previous run from Google Sheets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read previous run from Google Sheets": {
      "main": [
        [
          {
            "node": "Start Apify run",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Start Apify run": {
      "main": [
        [
          {
            "node": "Route by run status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by run status": {
      "main": [
        [
          {
            "node": "Download results",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Check run status",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Alert: run failed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check run status": {
      "main": [
        [
          {
            "node": "Route by run status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Download results": {
      "main": [
        [
          {
            "node": "Underwrite listings and pick the worthwhile ones",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Underwrite listings and pick the worthwhile ones": {
      "main": [
        [
          {
            "node": "Rows for the sheet",
            "type": "main",
            "index": 0
          },
          {
            "node": "Anything to report?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Rows for the sheet": {
      "main": [
        [
          {
            "node": "Save to Google Sheets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Anything to report?": {
      "main": [
        [
          {
            "node": "Email the report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Alert: run failed": {
      "main": [
        [
          {
            "node": "Stop: run failed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "pinData": {},
  "settings": {
    "executionOrder": "v1"
  },
  "meta": {
    "templateCredsSetupCompleted": false
  }
}
