{
  "id": "y0Yk7da21T4u9zlp",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Property Listing Aggregator with Microsoft Teams and Baserow",
  "tags": [],
  "nodes": [
    {
      "id": "4019642e-afcb-4f3b-8e5d-0b41b291f64c",
      "name": "Workflow Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -752,
        192
      ],
      "parameters": {
        "width": 528,
        "height": 704,
        "content": "## How it works\n\nThis workflow checks multiple commercial-real-estate portals once a week and delivers a consolidated stream of fresh or updated listings right inside a Microsoft Teams channel. A schedule trigger starts the run, then a Code node provides the list of search URLs you want to monitor. The list is split so each page can be scraped in parallel by ScrapeGraphAI, which returns structured JSON for every property it finds. A Merge node combines the separate streams and a second Code node normalises the data into a single flat list of listings. Each listing is compared against what already lives in Baserow; brand-new rows are inserted while changed rows are updated. Whenever a create or update happens, a short HTML message is crafted and pushed to Teams so stakeholders stay informed without leaving chat.\n\n## Setup steps\n\n1. Add your ScrapeGraphAI API credential.\n2. Put the real estate search URLs in the \u201cPrepare URL List\u201d Code node.\n3. Create a Baserow database and note the **Application ID** and **Table ID**.\n4. Map each Baserow field to the correct property in the \u201cCreate/Update Row\u201d nodes.\n5. Connect a Microsoft Teams OAuth2 credential and supply your Team + Channel IDs.\n6. Enable the workflow and run once manually to populate initial rows.\n7. Leave it active to receive weekly space availability alerts."
      },
      "typeVersion": 1
    },
    {
      "id": "44af3dd6-f701-4bd4-84d5-0de9521fbe1e",
      "name": "Section \u2013 Trigger & URL Prep",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -160,
        80
      ],
      "parameters": {
        "color": 7,
        "width": 530,
        "height": 718,
        "content": "## Trigger & URL Preparation\n\nA weekly **Schedule Trigger** kicks everything off so you never have to remember to run the workflow. Adjust the interval to suit your market\u2019s pace\u2014weekly is a good default for commercial space. The adjacent **Prepare URL List** Code node is the only place you typically edit when you want to target new brokers or alter search filters. It returns one item per URL so downstream nodes can process each website independently. Placing URLs in code keeps them version-controlled and prevents accidental changes in the UI. Remember to keep your list focused; many large portals paginate results aggressively and you can hit both their rate limits and your own ScrapeGraphAI quota if you paste dozens of links here. Stick to broad category or city search pages and let the AI handle pagination when needed."
      },
      "typeVersion": 1
    },
    {
      "id": "efd5c267-c144-435b-85d9-0ff60bd27e91",
      "name": "Section \u2013 Parallel Scraping",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        432,
        128
      ],
      "parameters": {
        "color": 7,
        "width": 562,
        "height": 670,
        "content": "## Parallel Scraping\n\nThe **Split in Batches** node takes the URL items one at a time but allows asynchronous execution so multiple pages can be scraped in parallel, shortening total runtime. **ScrapeGraphAI** does the heavy lifting: it renders each page (JavaScript included) and extracts key fields using your natural-language prompt. Because every site returns slightly different HTML, the AI\u2019s semantic approach is far more resilient than brittle CSS selectors. Output is a JSON object with a `listings` array. The **Merge (Combine)** node reunites the individual scrape results, ensuring that the following processing stage sees one consolidated data stream. If you add additional sources later, you only extend the URL list\u2014no structural workflow change needed."
      },
      "typeVersion": 1
    },
    {
      "id": "a5c0d10f-53d6-460e-a8aa-e48377d5dcab",
      "name": "Section \u2013 Normalisation & Flattening",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1024,
        128
      ],
      "parameters": {
        "color": 7,
        "width": 514,
        "height": 670,
        "content": "## Normalisation & Flattening\n\nDifferent portals phrase fields differently\u2014some use `rent`, some `price`, others hide size in text. The **Normalise Listings** Code node flattens every raw listing into a predictable schema: `listing_id`, `address`, `price`, `size_sqft`, `listing_url`, broker details, availability date, and a timestamp. This guarantees that Baserow fields stay tidy and that downstream comparison logic works reliably. It also stamps each row with the source URL so you can trace any weird value back to its origin during audits. Invalid or missing numbers are converted to `null` rather than empty strings, making later numeric filtering easier. The node finally returns one item per listing\u2014perfect for looping into storage operations."
      },
      "typeVersion": 1
    },
    {
      "id": "b0ee79e1-8f06-4500-8703-64e9e1b7ea4b",
      "name": "Section \u2013 Deduplication & Storage",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1568,
        128
      ],
      "parameters": {
        "color": 7,
        "width": 514,
        "height": 670,
        "content": "## Deduplication & Storage\n\nEach listing item first queries Baserow with an exact-match filter on `listing_id`. If nothing comes back, we create a brand-new row. When a row exists, a small Code node compares price, size, and availability to decide whether an update is warranted, preventing noisy \u201achange\u2019 notifications when nothing meaningful moved. Both **Create Row** and **Update Row** nodes respect your chosen field order so the database remains human-readable. Using Baserow keeps your stack open-source and offers a simple REST API if you want to share the data with BI tools or embed it in a website later. All write operations use credentials\u2014no keys in plain sight\u2014so you pass the security review on day one."
      },
      "typeVersion": 1
    },
    {
      "id": "aac64391-d5aa-44a2-ac1f-4eaa2a542a46",
      "name": "Section \u2013 Teams Notifications",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2128,
        176
      ],
      "parameters": {
        "color": 7,
        "width": 802,
        "height": 734,
        "content": "## Teams Notifications\n\nBusiness users rarely check databases, so actionable alerts land straight in Microsoft Teams. Two short **Set** nodes craft HTML messages\u2014one for new listings, one for updates\u2014highlighting address, price and size at a glance. The **Microsoft Teams** nodes then post to your chosen channel using OAuth2 credentials, which means users see the bot name instead of a generic webhook. Hyperlinks drive recipients back to the original broker page so they can call or email immediately. If you later need silent mode you can simply disable these connections without stopping the rest of the workflow, proving why separating storage and notification logic is worth the extra nodes."
      },
      "typeVersion": 1
    },
    {
      "id": "f8d03eae-3358-45e0-bb77-da26a38ea657",
      "name": "Weekly Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -144,
        384
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "weeks"
            }
          ]
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "85b31064-b13d-48f6-922f-0ef2442aed95",
      "name": "Prepare URL List",
      "type": "n8n-nodes-base.code",
      "position": [
        64,
        384
      ],
      "parameters": {
        "jsCode": "const urls = [\n  \"https://www.commercialsite1.com/office-space/xyz\",\n  \"https://www.commercialsite2.com/listings/xyz\",\n  \"https://brokerportal.example.com/spaces?city=xyz&type=office\"\n];\nreturn urls.map(url => ({ json: { url } }));"
      },
      "typeVersion": 2
    },
    {
      "id": "6b1d6abb-7963-463a-ac5a-92396cbb32cf",
      "name": "Split URLs",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        272,
        384
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 3
    },
    {
      "id": "d968b929-d7a7-4ca7-af1f-45d2f61bb892",
      "name": "Scrape Listings",
      "type": "n8n-nodes-scrapegraphai.scrapegraphAi",
      "position": [
        496,
        544
      ],
      "parameters": {
        "userPrompt": "Extract all commercial real estate listings from this page. Return JSON with a top-level key \\\"listings\\\" which is an array of objects, each containing: id, address, price, size_sqft, listing_url, broker_name, broker_phone, availability_date. Ensure numeric values are numbers and dates use ISO 8601.",
        "websiteUrl": "={{ $json.url }}"
      },
      "typeVersion": 1
    },
    {
      "id": "8c5b3453-6e22-437c-bd79-76f625015048",
      "name": "Collect Listings",
      "type": "n8n-nodes-base.merge",
      "position": [
        656,
        560
      ],
      "parameters": {
        "mode": "combine",
        "options": {},
        "mergeByFields": {
          "values": [
            {}
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "e23f0bc1-9fc0-43da-a075-f68acc306b54",
      "name": "Normalise Listings",
      "type": "n8n-nodes-base.code",
      "position": [
        816,
        544
      ],
      "parameters": {
        "jsCode": "// Flatten and normalise listings\nconst items = $input.all();\nlet listings = [];\nfor (const item of items) {\n  const sourceUrl = item.json.url || item.json.source || '';\n  const results = item.json.listings || [];\n  for (const entry of results) {\n    listings.push({\n      json: {\n        listing_id: entry.id || entry.listing_id || entry.listing_url || entry.url,\n        address: entry.address || '',\n        price: entry.price || null,\n        size_sqft: entry.size_sqft || null,\n        listing_url: entry.listing_url || entry.url || '',\n        broker_name: entry.broker_name || '',\n        broker_phone: entry.broker_phone || '',\n        availability_date: entry.availability_date || null,\n        source: sourceUrl,\n        scraped_at: new Date().toISOString()\n      }\n    });\n  }\n}\nreturn listings;"
      },
      "typeVersion": 2
    },
    {
      "id": "5c0d5533-d5a4-4fcc-a61c-36eba4aa063f",
      "name": "Loop Listings",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        1072,
        496
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 3
    },
    {
      "id": "52da4f99-1c45-4972-938c-0cb03a8adcfa",
      "name": "Check Existing (Baserow List)",
      "type": "n8n-nodes-base.baserow",
      "position": [
        1232,
        480
      ],
      "parameters": {
        "tableId": "={{ $env.BASEROW_TABLE_ID || '1' }}",
        "operation": "list"
      },
      "typeVersion": 1
    },
    {
      "id": "64e13c3c-2524-46d6-a50a-284b2253de58",
      "name": "Merge Listing & Result",
      "type": "n8n-nodes-base.merge",
      "position": [
        1376,
        480
      ],
      "parameters": {
        "mode": "combine",
        "options": {},
        "mergeByFields": {
          "values": [
            {}
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "57bdb17e-496c-4501-becf-8ae991c284d9",
      "name": "Determine Action",
      "type": "n8n-nodes-base.code",
      "position": [
        1616,
        528
      ],
      "parameters": {
        "jsCode": "const [listingData, baserowResponse] = $input.all().map(i => i.json);\nconst existing = Array.isArray(baserowResponse.results) ? baserowResponse.results : [];\nlet action = 'create';\nlet rowId = null;\nif (existing.length > 0) {\n  rowId = existing[0].id;\n  const changed = existing[0].price !== listingData.price || existing[0].size_sqft !== listingData.size_sqft || existing[0].availability_date !== listingData.availability_date;\n  action = changed ? 'update' : 'skip';\n}\nreturn [{ json: { ...listingData, _action: action, _rowId: rowId } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "797e28cc-e1c1-4595-bf02-8524a3076365",
      "name": "Need Create?",
      "type": "n8n-nodes-base.if",
      "position": [
        1792,
        528
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "string": [
            {
              "value1": "={{ $json._action }}",
              "value2": "create",
              "operation": "equals"
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "77b6b233-d32d-4250-a207-f16e73352f7b",
      "name": "Create Row",
      "type": "n8n-nodes-base.baserow",
      "position": [
        2320,
        416
      ],
      "parameters": {
        "tableId": "={{ $env.BASEROW_TABLE_ID || '1' }}",
        "operation": "create"
      },
      "typeVersion": 1
    },
    {
      "id": "d0e7cbe5-805b-4f22-b8c4-d11f525c0dfb",
      "name": "Create Message",
      "type": "n8n-nodes-base.set",
      "position": [
        2496,
        400
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 3
    },
    {
      "id": "75550ae7-12be-4d66-83a4-46915385d4fb",
      "name": "Teams \u2013 New Listing",
      "type": "n8n-nodes-base.microsoftTeams",
      "position": [
        2688,
        416
      ],
      "parameters": {
        "chatId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "message": "={{ $json.message }}",
        "options": {},
        "resource": "chatMessage",
        "contentType": "html"
      },
      "typeVersion": 2
    },
    {
      "id": "d31b9b56-da03-4848-878f-c903b42fd4f0",
      "name": "Need Update?",
      "type": "n8n-nodes-base.if",
      "position": [
        1952,
        528
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "string": [
            {
              "value1": "={{ $json._action }}",
              "value2": "update",
              "operation": "equals"
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "caa31adb-d387-4aa3-a54f-8096d6bfd2b5",
      "name": "Update Row",
      "type": "n8n-nodes-base.baserow",
      "position": [
        2208,
        688
      ],
      "parameters": {
        "rowId": "={{ $json._rowId }}",
        "tableId": "={{ $env.BASEROW_TABLE_ID || '1' }}",
        "operation": "update"
      },
      "typeVersion": 1
    },
    {
      "id": "9f1de02b-bc08-4634-a669-297e5372ba56",
      "name": "Update Message",
      "type": "n8n-nodes-base.set",
      "position": [
        2416,
        688
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 3
    },
    {
      "id": "83e0771c-2ba2-47df-a330-94bb3eb9d085",
      "name": "Teams \u2013 Update",
      "type": "n8n-nodes-base.microsoftTeams",
      "position": [
        2672,
        688
      ],
      "parameters": {
        "chatId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "message": "={{ $json.message }}",
        "options": {},
        "resource": "chatMessage",
        "contentType": "html"
      },
      "typeVersion": 2
    }
  ],
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "versionId": "70809248-7642-4baf-b0dc-436bb7a368aa",
  "connections": {
    "Create Row": {
      "main": [
        [
          {
            "node": "Create Message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split URLs": {
      "main": [
        [
          {
            "node": "Scrape Listings",
            "type": "main",
            "index": 0
          },
          {
            "node": "Collect Listings",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Update Row": {
      "main": [
        [
          {
            "node": "Update Message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Need Create?": {
      "main": [
        [
          {
            "node": "Create Row",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Need Update?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Need Update?": {
      "main": [
        [
          {
            "node": "Update Row",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Loop Listings": {
      "main": [
        [
          {
            "node": "Check Existing (Baserow List)",
            "type": "main",
            "index": 0
          },
          {
            "node": "Merge Listing & Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Message": {
      "main": [
        [
          {
            "node": "Teams \u2013 New Listing",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Update Message": {
      "main": [
        [
          {
            "node": "Teams \u2013 Update",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Weekly Trigger": {
      "main": [
        [
          {
            "node": "Prepare URL List",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Scrape Listings": {
      "main": [
        [
          {
            "node": "Collect Listings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Collect Listings": {
      "main": [
        [
          {
            "node": "Normalise Listings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Determine Action": {
      "main": [
        [
          {
            "node": "Need Create?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare URL List": {
      "main": [
        [
          {
            "node": "Split URLs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalise Listings": {
      "main": [
        [
          {
            "node": "Loop Listings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Listing & Result": {
      "main": [
        [
          {
            "node": "Determine Action",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Existing (Baserow List)": {
      "main": [
        [
          {
            "node": "Merge Listing & Result",
            "type": "main",
            "index": 1
          }
        ]
      ]
    }
  }
}