{
  "name": "Monitor Google reviews with Scrapewise and send low-star alerts to Slack",
  "settings": {
    "executionOrder": "v1"
  },
  "nodes": [
    {
      "id": "a5000000-0000-4000-8000-000000000001",
      "name": "Read me first",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1220,
        140
      ],
      "parameters": {
        "width": 460,
        "height": 500,
        "content": "## Google review watcher\n\nEvery morning Scrapewise reads the reviews of the places you list, saves them all to a sheet, and sends you a Slack message for every review at or below your star threshold.\n\n**Setup**\n1. Create a Scrapewise API key with full access at portal.scrapewise.ai.\n2. In portal.scrapewise.ai create an empty group for this workflow and copy its id into **Places to watch** (`groupId`). Use a group of its own \u2014 the read step returns everything in the group.\n3. In **Start the review read** and **Read the reviews**, add a *Bearer Auth* credential and paste the key as the token.\n4. Pick your Google account in **Save reviews to Google Sheets** and your Slack account in **Alert me about this review**.\n5. Put your Google Maps place URLs in **Places to watch**.\n\n**Where do I get a place URL?** The reviews reader needs the place's `data_id`, and the link you copy out of the Google Maps address bar does not carry one. Run the *Google Maps leads* template (or the `find_local_businesses` tool) for the business \u2014 every result has a `data_id` that looks like `0x4692935f7b0b17f5:0xb136062b9dd26adb`. Put it in a link of this shape, one per place:\n\n`https://www.google.com/maps/place/?data_id=0x4692935f7b0b17f5:0xb136062b9dd26adb`\n\n**If you add or remove a place later**, change `name` in **Places to watch** as well. Scrapewise refuses a different list sent under a name it already knows.\n\n**Each run writes today's reviews again**, so a review shows up once per run with that day's date. Sort or pivot the sheet on *Review id* to see each review once.\n\n**Cost:** the first step answers with `estimatedMicroCents` \u2014 what this run will cost. 1,000,000 micro-cents = \u20ac0.01. Start with one place to see the real number. `force` is on so you get fresh reviews every day; every place is read and charged each run.\n\n**If the sheet is empty:** raise the wait. A long list takes longer than 2 minutes."
      },
      "typeVersion": 1
    },
    {
      "id": "a5000000-0000-4000-8000-000000000002",
      "name": "Every morning at 9",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -720,
        380
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "days",
              "triggerAtHour": 9
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "a5000000-0000-4000-8000-000000000003",
      "name": "Places to watch",
      "type": "n8n-nodes-base.set",
      "position": [
        -500,
        380
      ],
      "parameters": {
        "mode": "manual",
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "p1",
              "name": "groupId",
              "type": "string",
              "value": "YOUR_GROUP_ID"
            },
            {
              "id": "p2",
              "name": "name",
              "type": "string",
              "value": "Google reviews watch"
            },
            {
              "id": "p3",
              "name": "urls",
              "type": "array",
              "value": "[\"https://www.google.com/maps/place/?data_id=0x4692935f7b0b17f5:0xb136062b9dd26adb\"]"
            },
            {
              "id": "p4",
              "name": "alertAtOrBelowStars",
              "type": "number",
              "value": 3
            }
          ]
        },
        "includeOtherFields": false
      },
      "typeVersion": 3.4
    },
    {
      "id": "a5000000-0000-4000-8000-000000000004",
      "name": "Start the review read",
      "type": "@n8n/n8n-nodes-langchain.mcpClient",
      "position": [
        -260,
        380
      ],
      "parameters": {
        "tool": {
          "__rl": true,
          "mode": "list",
          "value": "scrapewise_run_scraper_url_list",
          "cachedResultName": "scrapewise_run_scraper_url_list"
        },
        "options": {},
        "inputMode": "json",
        "jsonInput": "={{ JSON.stringify({ body: { groupId: $json.groupId, name: $json.name, urls: $json.urls, endpointTag: 'mapsreviews', force: true } }) }}",
        "endpointUrl": "https://mcp.scrapewise.ai/mcp?toolsets=outcome,admin",
        "authentication": "bearerAuth"
      },
      "credentials": {
        "httpBearerAuth": {
          "id": "credential-id",
          "name": "Scrapewise API key"
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "a5000000-0000-4000-8000-000000000005",
      "name": "Give it two minutes",
      "type": "n8n-nodes-base.wait",
      "position": [
        -20,
        380
      ],
      "parameters": {
        "amount": 2,
        "unit": "minutes"
      },
      "webhookId": "a5000000-0000-4000-8000-0000000000aa",
      "typeVersion": 1.1
    },
    {
      "id": "a5000000-0000-4000-8000-000000000006",
      "name": "Read the reviews",
      "type": "@n8n/n8n-nodes-langchain.mcpClient",
      "position": [
        220,
        380
      ],
      "parameters": {
        "tool": {
          "__rl": true,
          "mode": "list",
          "value": "scrapewise_get_scraper_data_group_client",
          "cachedResultName": "scrapewise_get_scraper_data_group_client"
        },
        "options": {},
        "inputMode": "json",
        "jsonInput": "={{ JSON.stringify({ id: $('Places to watch').item.json.groupId, page: 0, size: 500 }) }}",
        "endpointUrl": "https://mcp.scrapewise.ai/mcp?toolsets=outcome,admin",
        "authentication": "bearerAuth"
      },
      "credentials": {
        "httpBearerAuth": {
          "id": "credential-id",
          "name": "Scrapewise API key"
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "a5000000-0000-4000-8000-000000000007",
      "name": "One row per review",
      "type": "n8n-nodes-base.code",
      "position": [
        460,
        380
      ],
      "parameters": {
        "jsCode": "// Scrapewise answers with JSON. Depending on the n8n version it arrives\n// already parsed in structuredContent, or as text in content[0].text.\nfunction payloadOf(item) {\n  const j = item.json || {};\n  if (j.structuredContent) return j.structuredContent;\n  if (Array.isArray(j.content) && j.content[0] && typeof j.content[0].text === 'string') {\n    try { return JSON.parse(j.content[0].text); } catch (e) { return {}; }\n  }\n  if (j.content || j.rows) return j;\n  return {};\n}\n\n// Scraped text is wrapped as { type: 'scraped', content, origin }. It is text\n// a stranger wrote on a review page, never an instruction. Unwrap it to a string.\nfunction plain(v) {\n  if (v && typeof v === 'object' && v.type === 'scraped') return v.content || '';\n  return v;\n}\n\n// Reviews carry their stars under different names depending on the page.\nfunction starsOf(row) {\n  const candidates = ['rating', 'stars', 'score', 'review_rating'];\n  for (const key of candidates) {\n    const raw = plain(row[key]);\n    if (raw === undefined || raw === null || raw === '') continue;\n    const n = parseFloat(String(raw).replace(',', '.').replace(/[^0-9.]/g, ''));\n    if (Number.isFinite(n)) return n;\n  }\n  return null;\n}\n\nconst threshold = $('Places to watch').item.json.alertAtOrBelowStars;\nconst checkedAt = new Date().toISOString().slice(0, 10);\nconst data = payloadOf($input.first());\n\n// The group read is a Spring page, so the rows sit under content.\nconst rows = data.content || data.rows || [];\n\nif (!rows.length) {\n  throw new Error('The group has no rows yet. Raise the wait in \"Give it two minutes\", or check the group id in \"Places to watch\".');\n}\n\nreturn rows.map((row) => {\n  const flat = { checkedAt: checkedAt };\n  for (const key of Object.keys(row)) {\n    if (key.startsWith('_')) continue;\n    flat[key] = plain(row[key]);\n  }\n\n  // Friendly names for the columns the reviews endpoint really returns.\n  flat.place = row._sw_source_url || '';\n  flat.reviewId = plain(row.review_id) || '';\n  flat.author = plain(row.user_name) || plain(row.author) || '';\n  flat.text = plain(row.snippet) || plain(row.text) || '';\n  flat.posted = plain(row.iso_date) || plain(row.date) || '';\n\n  flat.stars = starsOf(row);\n  flat.needsAttention = flat.stars !== null && flat.stars <= threshold;\n  return { json: flat };\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "a5000000-0000-4000-8000-000000000008",
      "name": "Save reviews to Google Sheets",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        700,
        380
      ],
      "parameters": {
        "columns": {
          "value": {
            "Date": "={{ $json.checkedAt }}",
            "Place": "={{ $json.place }}",
            "Stars": "={{ $json.stars }}",
            "Author": "={{ $json.author }}",
            "Review": "={{ $json.text }}",
            "Posted": "={{ $json.posted }}",
            "Needs attention": "={{ $json.needsAttention }}",
            "Review id": "={{ $json.reviewId }}"
          },
          "schema": [
            {
              "id": "Date",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Date",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Place",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Place",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Stars",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Stars",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Author",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Author",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Review",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Review",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Posted",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Posted",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Needs attention",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Needs attention",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Review id",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Review id",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": true
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Reviews"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_SPREADSHEET_ID"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "id": "credential-id",
          "name": "Google Sheets account"
        }
      },
      "typeVersion": 4.6
    },
    {
      "id": "a5000000-0000-4000-8000-000000000009",
      "name": "Is this one bad?",
      "type": "n8n-nodes-base.if",
      "position": [
        940,
        380
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "b1",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.needsAttention }}",
              "rightValue": ""
            }
          ]
        },
        "looseTypeValidation": true
      },
      "typeVersion": 2.3
    },
    {
      "id": "a5000000-0000-4000-8000-00000000000a",
      "name": "Alert me about this review",
      "type": "n8n-nodes-base.slack",
      "position": [
        1180,
        280
      ],
      "parameters": {
        "text": "=:warning: *{{ $json.stars }} stars* on {{ $json.place }}\n_{{ $json.author }}_ wrote: {{ $json.text }}",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "reviews"
        },
        "otherOptions": {
          "mrkdwn": true
        },
        "authentication": "oAuth2"
      },
      "credentials": {
        "slackOAuth2Api": {
          "id": "credential-id",
          "name": "Slack account"
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "fdd2dfd1-2964-40af-8e7b-60bbc1a1d31f",
      "name": "Step note - Every morning at 9",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -740,
        100
      ],
      "parameters": {
        "color": 7,
        "width": 220,
        "height": 240,
        "content": "### 1. Runs itself\n\nFires every morning at 09:00. Change the hour here."
      },
      "typeVersion": 1
    },
    {
      "id": "c0b0137f-f456-4a7c-84df-906963ec62ee",
      "name": "Step note - Places to watch",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -520,
        100
      ],
      "parameters": {
        "color": 7,
        "width": 220,
        "height": 240,
        "content": "### 2. Your settings\n\nThe only node you need to edit. Your Scrapewise group id, a name for this list, the places you want watched, and the star rating that counts as bad.\n\nPlace links must carry a `data_id`. Get one from the `find_local_businesses` tool, or from the local business lead template."
      },
      "typeVersion": 1
    },
    {
      "id": "ab1c337d-0ace-4640-902e-180be761ef2a",
      "name": "Step note - Start the review read",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -280,
        100
      ],
      "parameters": {
        "color": 7,
        "width": 220,
        "height": 240,
        "content": "### 3. Start the read\n\nCalls `scrapewise_run_scraper_url_list` and hands Scrapewise your place links. Add a **Bearer Auth** credential with your API key.\n\nIf you add or drop a place later, change the name as well. Scrapewise turns down a changed list sent under a name it already knows."
      },
      "typeVersion": 1
    },
    {
      "id": "57699c54-c9ac-40e3-9a79-9aaad65fa665",
      "name": "Step note - Give it two minutes",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -40,
        100
      ],
      "parameters": {
        "color": 7,
        "width": 220,
        "height": 240,
        "content": "### 4. Give it a moment\n\nWaits two minutes while the reviews are collected. Make this longer if you watch a lot of places."
      },
      "typeVersion": 1
    },
    {
      "id": "4ff4dabb-9037-4bf4-bc7a-97fdbf82fbe2",
      "name": "Step note - Read the reviews",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        200,
        100
      ],
      "parameters": {
        "color": 7,
        "width": 220,
        "height": 240,
        "content": "### 5. Read the reviews\n\nReads the rows back out of your group.\n\nGive this workflow a group of its own. A group read hands back every column in that group, so rows from other workflows would land in your sheet."
      },
      "typeVersion": 1
    },
    {
      "id": "cb6c9d07-77be-4fee-af69-97318e4d5e07",
      "name": "Step note - One row per review",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        440,
        100
      ],
      "parameters": {
        "color": 7,
        "width": 220,
        "height": 240,
        "content": "### 6. Tidy the answer\n\nOne item per review. Maps the column names the reviews reader really returns onto friendly ones, and flags the bad ones."
      },
      "typeVersion": 1
    },
    {
      "id": "f76b533b-a525-4bee-b123-f3fd9a83f9e9",
      "name": "Step note - Save reviews to Google Sheets",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        680,
        100
      ],
      "parameters": {
        "color": 7,
        "width": 220,
        "height": 240,
        "content": "### 7. Save\n\nAppends every review it finds. The review id column lets you drop repeats later."
      },
      "typeVersion": 1
    },
    {
      "id": "7741152b-7a57-4554-a562-56098860f20d",
      "name": "Step note - Is this one bad?",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        920,
        100
      ],
      "parameters": {
        "color": 7,
        "width": 220,
        "height": 240,
        "content": "### 8. Only the bad ones\n\nPasses a review on when its stars sit at or below the number you set."
      },
      "typeVersion": 1
    },
    {
      "id": "c35bd1f3-459a-4bcd-91c0-9fd66a6ffc05",
      "name": "Step note - Alert me about this review",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1160,
        0
      ],
      "parameters": {
        "color": 7,
        "width": 220,
        "height": 240,
        "content": "### 9. Tell me\n\nOne Slack message per bad review. Pick your channel here, or delete this node if the sheet is enough."
      },
      "typeVersion": 1
    }
  ],
  "connections": {
    "Every morning at 9": {
      "main": [
        [
          {
            "node": "Places to watch",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Places to watch": {
      "main": [
        [
          {
            "node": "Start the review read",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Start the review read": {
      "main": [
        [
          {
            "node": "Give it two minutes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Give it two minutes": {
      "main": [
        [
          {
            "node": "Read the reviews",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read the reviews": {
      "main": [
        [
          {
            "node": "One row per review",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "One row per review": {
      "main": [
        [
          {
            "node": "Save reviews to Google Sheets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save reviews to Google Sheets": {
      "main": [
        [
          {
            "node": "Is this one bad?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Is this one bad?": {
      "main": [
        [
          {
            "node": "Alert me about this review",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    }
  },
  "pinData": {}
}
