{
  "name": "Find local businesses with weak Google profiles using Bright Data, GPT-5.6, Google Sheets and Slack",
  "nodes": [
    {
      "id": "sticky-main",
      "name": "Sticky Note main",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -700,
        110
      ],
      "parameters": {
        "color": 1,
        "width": 560,
        "height": 933,
        "content": "## Find local businesses with a weak Google profile, and the proof\n\nGive it a city and a few categories and it comes back with the businesses whose Google Business Profile is measurably behind their neighbours, each one with the specific defect that makes it worth a call.\n\n### How it works\nA weekly schedule sends one Google Maps search per category through Bright Data's discovery API. Discovery takes about a minute and a half, so the workflow uses the asynchronous API properly: it triggers the search, then polls the snapshot on a Wait loop with a seven-minute cap until the rows are ready.\n\nEvery profile is then audited against the peers Google itself lists beside it, which is what turns a rating into a judgement: unclaimed listing, missing website, rating below the local median, a heavy tail of one and two star reviews, thin photos, no opening hours. Each gap carries a weight and the total is the score. GPT-5.6 reads only the reviews rated three stars and below, for complaint themes. Every factual claim in the output comes from a field, not from the model.\n\nProspects already reported are skipped for ninety days, the whole list lands in Google Sheets, and a ranked digest goes to Slack.\n\n### Setup\nAdd a Bright Data API key as a Header Auth credential (`Authorization` / `Bearer YOUR_KEY`), connect OpenAI, Google Sheets and Slack, then set your city, categories and channel in Set Search Config.\n\n### Customization\nChange the weights in Score Profile Gaps to match what you sell: lead with unclaimed listings for a profile management offer, or with the review gaps for reputation work."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-sec1",
      "name": "Sticky Note sec1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -60,
        310
      ],
      "parameters": {
        "color": 7,
        "width": 696,
        "height": 366,
        "content": "## 1. Pick the patch\nOne city, one or more categories, on a weekly schedule. Everything you would tune lives in one config node."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-sec2",
      "name": "Sticky Note sec2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        816,
        310
      ],
      "parameters": {
        "color": 7,
        "width": 1416,
        "height": 366,
        "content": "## 2. Search Maps asynchronously\nBright Data runs one Google Maps search per category. Discovery took about 90 seconds in testing, so this polls the snapshot until the rows are ready."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-sec3",
      "name": "Sticky Note sec3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2412,
        310
      ],
      "parameters": {
        "color": 7,
        "width": 456,
        "height": 366,
        "content": "## 3. Audit every profile\nEach listing is scored on the gaps you can prove, benchmarked against the peers Google lists beside it."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-sec4",
      "name": "Sticky Note sec4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3048,
        110
      ],
      "parameters": {
        "color": 7,
        "width": 936,
        "height": 566,
        "content": "## 4. Drop the ones you have had\nThe sheet doubles as the do-not-repeat list, so a business reported recently stays out of this week's digest."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-sec5",
      "name": "Sticky Note sec5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4164,
        310
      ],
      "parameters": {
        "color": 7,
        "width": 952,
        "height": 590,
        "content": "## 5. Read the bad reviews\nThe model sees only reviews rated three stars and below, and returns complaint themes. Everything stated as fact about a business comes from a field, not from the model."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-sec6",
      "name": "Sticky Note sec6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        5296,
        310
      ],
      "parameters": {
        "color": 7,
        "width": 936,
        "height": 366,
        "content": "## 6. Deliver\nOne sheet row per prospect, which is what the next run reads back, plus a ranked Slack digest."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-warn",
      "name": "Sticky Note warn",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        816,
        736
      ],
      "parameters": {
        "color": 3,
        "width": 380,
        "height": 199,
        "content": "## Search one city at a time\nBright Data's Maps discovery takes a keyword and a country, with no separate location field, so the city travels inside the keyword. Adding categories adds a search each, and every search adds to the wait."
      },
      "typeVersion": 1
    },
    {
      "id": "t6-trigger",
      "name": "Weekly Prospect Scan",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        0,
        460
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "days",
              "triggerAtHour": 7,
              "triggerAtMinute": 0
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "t6-cfg",
      "name": "Set Search Config",
      "type": "n8n-nodes-base.set",
      "position": [
        240,
        460
      ],
      "parameters": {
        "mode": "manual",
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "c0",
              "name": "city",
              "type": "string",
              "value": "Austin, TX"
            },
            {
              "id": "c1",
              "name": "categories",
              "type": "string",
              "value": "dentist, chiropractor"
            },
            {
              "id": "c2",
              "name": "country",
              "type": "string",
              "value": "US"
            },
            {
              "id": "c3",
              "name": "businesses_per_category",
              "type": "number",
              "value": 20
            },
            {
              "id": "c4",
              "name": "min_reviews",
              "type": "number",
              "value": 10
            },
            {
              "id": "c5",
              "name": "min_gap_score",
              "type": "number",
              "value": 15
            },
            {
              "id": "c6",
              "name": "prospects_per_run",
              "type": "number",
              "value": 8
            },
            {
              "id": "c7",
              "name": "recontact_after_days",
              "type": "number",
              "value": 90
            },
            {
              "id": "c8",
              "name": "sheet_url",
              "type": "string",
              "value": "https://docs.google.com/spreadsheets/d/YOUR_SHEET_ID"
            },
            {
              "id": "c9",
              "name": "slack_channel",
              "type": "string",
              "value": "#prospecting"
            }
          ]
        },
        "includeOtherFields": false
      },
      "typeVersion": 3.4
    },
    {
      "id": "t6-input",
      "name": "Build Discovery Input",
      "type": "n8n-nodes-base.code",
      "position": [
        480,
        460
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// single-item input: the config node emits exactly one row.\nconst cfg = $input.first().json;\n\nconst city = String(cfg.city || '').trim();\nconst categories = String(cfg.categories || '')\n  .split(',')\n  .map((c) => c.trim())\n  .filter(Boolean);\n\nif (!city || !categories.length) {\n  throw new Error('Set both city and categories in ' + 'Set Search Config' + '.');\n}\n\n// Bright Data's Maps discovery takes {keyword, country} and rejects a separate\n// location field outright, so the city has to travel inside the keyword - the\n// same way you would type it into Google Maps.\nconst payload = categories.map((category) => ({\n  keyword: `${category} in ${city}`,\n  country: String(cfg.country || 'US').toUpperCase(),\n}));\n\nreturn [{ json: { payload, city, categories, scanned_at: new Date().toISOString() } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "t6-fetch",
      "name": "Discover Businesses With Bright Data",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "maxTries": 3,
      "position": [
        876,
        460
      ],
      "parameters": {
        "url": "=https://api.brightdata.com/datasets/v3/trigger?dataset_id=gd_m8ebnr0q2qlklc02fz&format=json&include_errors=true&type=discover_new&discover_by=location&limit_per_input={{ $('Set Search Config').first().json.businesses_per_category }}",
        "method": "POST",
        "options": {
          "timeout": 120000,
          "response": {
            "response": {
              "neverError": true
            }
          }
        },
        "jsonBody": "={{ JSON.stringify($json.payload) }}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "retryOnFail": true,
      "typeVersion": 4.4,
      "waitBetweenTries": 2000
    },
    {
      "id": "t6-accept",
      "name": "Check Discovery Accepted",
      "type": "n8n-nodes-base.code",
      "position": [
        1116,
        460
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// single-item input: the trigger endpoint answers once with one snapshot id.\nconst res = $input.first().json;\n\n// Bright Data answers validation errors with HTTP 200 and an error body, and\n// this node runs with neverError, so an unchecked response looks like a success\n// and fails several nodes later blaming the wrong thing.\nif (res.error || res.errors) {\n  throw new Error('Bright Data rejected the search: ' + JSON.stringify(res.error || res.errors));\n}\nif (!res.snapshot_id) {\n  throw new Error('No snapshot_id in the Bright Data response: ' + JSON.stringify(res).slice(0, 400));\n}\n\nreturn [{ json: { snapshot_id: res.snapshot_id } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "t6-wait",
      "name": "Wait For Snapshot",
      "type": "n8n-nodes-base.wait",
      "position": [
        1356,
        460
      ],
      "parameters": {
        "unit": "seconds",
        "amount": 20
      },
      "typeVersion": 1.1
    },
    {
      "id": "t6-progress",
      "name": "Check Snapshot Progress",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "maxTries": 3,
      "position": [
        1596,
        460
      ],
      "parameters": {
        "url": "=https://api.brightdata.com/datasets/v3/progress/{{ $('Check Discovery Accepted').first().json.snapshot_id }}",
        "method": "GET",
        "options": {
          "timeout": 60000,
          "response": {
            "response": {
              "neverError": true
            }
          }
        },
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "retryOnFail": true,
      "typeVersion": 4.4,
      "waitBetweenTries": 2000
    },
    {
      "id": "t6-eval",
      "name": "Evaluate Scrape Progress",
      "type": "n8n-nodes-base.code",
      "position": [
        1836,
        460
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const MAX_POLLS = 20; // 20 x 20s = about 7 minutes\n\n// single-item input: the progress endpoint returns one status object.\nconst res = $input.first().json;\nconst snapshotId = $('Check Discovery Accepted').first().json.snapshot_id;\nconst attempt = $runIndex + 1;\n\nif (res.error) {\n  throw new Error('Progress check failed: ' + JSON.stringify(res.error));\n}\nif (res.status === 'failed') {\n  throw new Error('Bright Data reported the snapshot failed: ' + JSON.stringify(res).slice(0, 400));\n}\nif (res.status !== 'ready' && attempt >= MAX_POLLS) {\n  throw new Error(\n    'Snapshot ' + snapshotId + ' still ' + res.status + ' after ' + attempt +\n    ' polls. Raise MAX_POLLS or search fewer categories at once.'\n  );\n}\n\nreturn [{ json: { status: res.status, snapshot_id: snapshotId, attempt, records: res.records || 0 } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "t6-ready",
      "name": "Profiles Ready?",
      "type": "n8n-nodes-base.if",
      "position": [
        2076,
        460
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond1",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.status }}",
              "rightValue": "ready"
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "t6-download",
      "name": "Download Business Profiles",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "maxTries": 3,
      "position": [
        2472,
        460
      ],
      "parameters": {
        "url": "=https://api.brightdata.com/datasets/v3/snapshot/{{ $('Check Discovery Accepted').first().json.snapshot_id }}?format=json",
        "method": "GET",
        "options": {
          "timeout": 180000,
          "response": {
            "response": {
              "neverError": true
            }
          }
        },
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "retryOnFail": true,
      "typeVersion": 4.4,
      "waitBetweenTries": 2000
    },
    {
      "id": "t6-score",
      "name": "Score Profile Gaps",
      "type": "n8n-nodes-base.code",
      "position": [
        2712,
        460
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const cfg = $('Set Search Config').first().json;\nconst minReviews = Number(cfg.min_reviews) || 0;\nconst minScore = Number(cfg.min_gap_score) || 0;\n\nconst median = (arr) => {\n  if (!arr.length) return 0;\n  const s = [...arr].sort((a, b) => a - b);\n  const m = Math.floor(s.length / 2);\n  return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;\n};\n\nconst seen = new Set();\nconst audited = [];\n// Every profile that got as far as being scored. The digest reports this rather\n// than the number of prospects, so \"6 profiles audited, 1 worth contacting\"\n// stays honest about how much was looked at.\nlet examined = 0;\n\nfor (const item of $input.all()) {\n  const j = item.json;\n  if (j.error || j.warning) continue;\n\n  const placeId = String(j.place_id || j.cid || '');\n  if (!placeId || seen.has(placeId)) continue;\n  seen.add(placeId);\n\n  // A closed business is not a prospect, and its profile gaps are meaningless.\n  if (j.permanently_closed === true || j.temporarily_closed === true) continue;\n\n  const reviews = Number(j.reviews_count) || 0;\n  const rating = Number(j.rating) || 0;\n  // Below a handful of reviews there is no rating signal to judge, so a quiet\n  // new business would otherwise be flagged for problems it does not have.\n  if (reviews < minReviews) continue;\n\n  examined += 1;\n\n  // --- the peer set Google itself puts next to this business ---------------\n  const peers = (j.people_also_search || [])\n    .filter((p) => Number(p.rating) > 0 && Number(p.reviews_num) > 0);\n  const peerRating = median(peers.map((p) => Number(p.rating)));\n  const peerReviews = median(peers.map((p) => Number(p.reviews_num)));\n\n  // --- the measurable facts ------------------------------------------------\n  const dist = j.review_distribution || {};\n  const distTotal = ['1', '2', '3', '4', '5']\n    .reduce((sum, k) => sum + (Number(dist[k + '_star_reviews']) || 0), 0);\n  const negative = (Number(dist['1_star_reviews']) || 0) + (Number(dist['2_star_reviews']) || 0);\n  const negativePct = distTotal ? Math.round((negative / distTotal) * 1000) / 10 : 0;\n\n  const hours = j.open_hours || {};\n  const openDays = Object.values(hours)\n    .filter((v) => v && !/closed/i.test(String(v))).length;\n\n  const photos = (j.photos_and_videos || []).length;\n  const categories = (j.all_categories || []).length;\n  const services = (j.services_provided || []).length;\n  const claimed = j.is_claimed !== false;\n  const website = String(j.open_website || '').trim();\n\n  // --- the gaps, each worth what it is worth to whoever is selling ---------\n  // An unclaimed listing outranks everything else here: nobody is managing the\n  // profile at all, which is both the easiest sale and the clearest evidence.\n  // Each gap carries a stable key as well as its sentence, because the sentence\n  // has this business's own numbers in it and so can never be counted across a\n  // list. The key is what the summary groups by.\n  const gaps = [];\n  const add = (points, key, label) => gaps.push({ points, key, label });\n\n  if (!claimed) add(25, 'unclaimed', 'the listing is unclaimed');\n  if (!website) add(20, 'no_website', 'no website on the profile');\n  if (peerRating && rating && peerRating - rating >= 0.3) {\n    add(15, 'rating_below_peers',\n      `rated ${rating} against ${peerRating} for nearby ${String(j.category || 'businesses').toLowerCase()}s`);\n  }\n  if (negativePct >= 15) add(15, 'negative_reviews', `${negativePct}% of reviews are 1 or 2 star`);\n  if (peerReviews && reviews < peerReviews * 0.4) {\n    add(12, 'few_reviews', `${reviews} reviews against a local median of ${peerReviews}`);\n  }\n  if (photos <= 3) add(8, 'few_photos', `only ${photos} photos on the profile`);\n  if (!openDays) add(10, 'no_hours', 'no opening hours listed');\n  if (categories <= 1) add(5, 'single_category', 'listed under a single category');\n  if (!services) add(5, 'no_services', 'no services or attributes listed');\n\n  const score = gaps.reduce((sum, g) => sum + g.points, 0);\n  if (score < minScore) continue;\n\n  // Negative reviews are what the model reads. Anything above 3 stars tells you\n  // nothing about what to fix.\n  const negativeReviews = (j.top_reviews || [])\n    .filter((r) => Number(r.rating) > 0 && Number(r.rating) <= 3)\n    .slice(0, 5)\n    .map((r) => `${r.rating} stars: ${String(r.content || '').replace(/\\s+/g, ' ').slice(0, 300)}`)\n    .filter((t) => t.length > 20);\n\n  audited.push({\n    json: {\n      place_id: placeId,\n      name: String(j.name || '').trim(),\n      category: String(j.category || '').trim(),\n      address: String(j.address || '').trim(),\n      phone: String(j.phone_number || '').trim(),\n      website,\n      // The dataset's own url runs to several hundred characters of Google\n      // routing. The cid form opens the same listing and fits in a sheet cell.\n      maps_url: j.cid ? `https://www.google.com/maps?cid=${j.cid}` : String(j.url || ''),\n      rating,\n      reviews,\n      negative_reviews: negative,\n      negative_pct: negativePct,\n      claimed,\n      photos,\n      open_days: openDays,\n      categories_listed: categories,\n      peer_rating: peerRating,\n      peer_reviews: peerReviews,\n      gap_score: score,\n      gaps: gaps.sort((a, b) => b.points - a.points).map((g) => g.label),\n      gap_keys: gaps.map((g) => g.key),\n      negative_review_text: negativeReviews.join('\\n'),\n      searched_city: cfg.city,\n    },\n  });\n}\n\n// Stamped after the loop so every row carries the final count.\nfor (const row of audited) row.json.examined = examined;\n\nif (!audited.length) {\n  throw new Error(\n    'No business profiles came back with a gap worth reporting. Lower ' +\n    'min_gap_score, widen the categories, or check that the city and category ' +\n    'match a real Google Maps search.'\n  );\n}\n\nreturn audited.sort((a, b) => b.json.gap_score - a.json.gap_score);"
      },
      "typeVersion": 2
    },
    {
      "id": "t6-logged",
      "name": "Read Reported Businesses",
      "type": "n8n-nodes-base.googleSheets",
      "onError": "continueRegularOutput",
      "position": [
        3108,
        460
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Prospects"
        },
        "documentId": {
          "__rl": true,
          "mode": "url",
          "value": "={{ $('Set Search Config').first().json.sheet_url }}"
        }
      },
      "typeVersion": 4.7,
      "alwaysOutputData": true
    },
    {
      "id": "t6-select",
      "name": "Select New Prospects",
      "type": "n8n-nodes-base.code",
      "position": [
        3348,
        460
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const cfg = $('Set Search Config').first().json;\nconst limit = Number(cfg.prospects_per_run) || 10;\nconst cooldownDays = Number(cfg.recontact_after_days) || 90;\n\n// The sheet read runs with alwaysOutputData, so on the very first run this is a\n// single empty item rather than no items. Treat that as \"nothing reported yet\"\n// instead of letting an undefined row through as a real record.\nconst logged = $input.all()\n  .map((i) => i.json || {})\n  .filter((r) => r && r.place_id);\n\nconst cutoff = Date.now() - cooldownDays * 86400000;\nconst recent = new Set(\n  logged\n    .filter((r) => {\n      const t = Date.parse(String(r.logged_at || ''));\n      // A row with an unreadable date still counts as reported. Treating it as\n      // fair game would put the same business back in the digest every week.\n      return isNaN(t) ? true : t >= cutoff;\n    })\n    .map((r) => String(r.place_id))\n);\n\nconst audited = $('Score Profile Gaps').all().map((i) => i.json);\nconst fresh = audited.filter((b) => !recent.has(String(b.place_id)));\n\n// A week where every business found was already reported is a normal outcome,\n// not an error. Returning nothing would end the branch here and the IF below\n// would never run, so nothing would mark the run as finished - hence one marker\n// item with no place_id, which routes to the quiet branch.\nif (!fresh.length) {\n  return [{\n    json: {\n      no_new_prospects: true,\n      audited: audited.length,\n      already_reported: audited.length - fresh.length,\n      city: cfg.city,\n    },\n  }];\n}\n\nreturn fresh.slice(0, limit).map((json) => ({ json }));"
      },
      "typeVersion": 2
    },
    {
      "id": "t6-any",
      "name": "Any New Prospects?",
      "type": "n8n-nodes-base.if",
      "position": [
        3588,
        460
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond1",
              "operator": {
                "type": "string",
                "operation": "exists",
                "singleValue": true
              },
              "leftValue": "={{ $json.place_id }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "t6-none",
      "name": "Nothing New This Week",
      "type": "n8n-nodes-base.noOp",
      "position": [
        3828,
        260
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "t6-extract",
      "name": "Read Complaint Themes",
      "type": "@n8n/n8n-nodes-langchain.informationExtractor",
      "onError": "continueRegularOutput",
      "position": [
        4224,
        460
      ],
      "parameters": {
        "text": "=Read the customer reviews below and report what people complain about.\n\nOnly report themes that appear in the reviews. If the review text is empty or\nsays none, set `has_complaints` to false and return an empty list of themes. Do\nnot infer complaints from the ratings, and do not repeat the business name back\nas a theme.\n\nEach theme should be two to four words describing the problem in plain language,\nlower case: \"long wait times\", \"surprise billing\", \"rude front desk\", \"poor\nworkmanship\".\n\nBusiness: {{ $json.name }}\nCategory: {{ $json.category }}\nOverall rating: {{ $json.rating }} from {{ $json.reviews }} reviews\n\nReviews rated 3 stars or below:\n{{ $json.negative_review_text || 'none' }}",
        "options": {},
        "schemaType": "fromJson",
        "jsonSchemaExample": "{\n  \"complaint_themes\": [\n    \"long wait times\",\n    \"surprise billing\"\n  ],\n  \"biggest_complaint\": \"the one problem mentioned most often\",\n  \"review_sentiment\": \"mostly positive | mixed | mostly negative\",\n  \"has_complaints\": true\n}"
      },
      "typeVersion": 1.2
    },
    {
      "id": "t6-llm1",
      "name": "OpenAI Review Reader",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "onError": "continueRegularOutput",
      "position": [
        4232,
        700
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-5.6-terra"
        },
        "options": {
          "temperature": 0.2
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "t6-rank",
      "name": "Rank Prospects",
      "type": "n8n-nodes-base.code",
      "position": [
        4592,
        460
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const cfg = $('Set Search Config').first().json;\nconst prospects = $('Select New Prospects').all().map((i) => i.json);\n// The extractor nests its result under `output` on some versions. Reading the\n// item directly gives undefined themes on every business, which produces an\n// empty digest on a run where every node is green.\nconst read = $input.all().map((i) => (i.json && i.json.output) || i.json || {});\n\n// The model ignores the enum often enough to matter - one run came back with\n// \"very negative\", which is not one of the three values the schema offers and\n// would sit in the sheet as a fourth category.\nconst SENTIMENT = (v) => {\n  const t = String(v || '').toLowerCase();\n  if (/mixed|neutral/.test(t)) return 'mixed';\n  if (/negative|poor|bad/.test(t)) return 'mostly negative';\n  if (/positive|good/.test(t)) return 'mostly positive';\n  return 'unclear';\n};\n\n// The extractor emits one item per input item in the same order, so index\n// alignment is the join key.\nconst rows = prospects.map((p, i) => {\n  const r = read[i] || {};\n  const themes = (r.has_complaints === false ? [] : (r.complaint_themes || []))\n    .map((t) => String(t || '').toLowerCase().trim())\n    .filter((t) => t.length > 2 && t.length < 45)\n    .slice(0, 4);\n\n  // Business names on Maps are written by their owners, and a trailing full stop\n  // is common enough - \"Dentist in Austin.\" - to read as a typo once the name is\n  // followed by a colon.\n  const name = String(p.name || '').replace(/[.\\s]+$/, '');\n\n  // The pitch is assembled from measured fields and the themes the model found\n  // in the review text. Nothing here is the model's opinion of the business,\n  // which is what makes every line of it something you can show the owner.\n  const evidence = p.gaps.slice(0, 2).join(' and ');\n  const complaint = themes.length ? ` Reviewers mention ${themes.slice(0, 2).join(' and ')}.` : '';\n  const pitch = `${name}: ${evidence}.${complaint}`;\n\n  return {\n    ...p,\n    name,\n    complaint_themes: themes,\n    biggest_complaint: themes.length ? String(r.biggest_complaint || themes[0]) : '',\n    review_sentiment: SENTIMENT(r.review_sentiment),\n    pitch,\n  };\n});\n\nconst tally = (key) => {\n  const m = {};\n  for (const r of rows) for (const v of r[key] || []) m[v] = (m[v] || 0) + 1;\n  return Object.entries(m).sort((a, b) => b[1] - a[1]).map(([name, count]) => ({ name, count }));\n};\n\nreturn [{\n  json: {\n    city: cfg.city,\n    categories: cfg.categories,\n    scanned_at: new Date().toISOString().slice(0, 10),\n    // How many profiles were looked at, not how many made the list.\n    audited_count: Number($('Score Profile Gaps').first().json.examined) || rows.length,\n    prospect_count: rows.length,\n    unclaimed_count: rows.filter((r) => !r.claimed).length,\n    no_website_count: rows.filter((r) => !r.website).length,\n    common_gaps: (() => {\n      const GAP_NAMES = {\n        unclaimed: 'unclaimed listing',\n        no_website: 'no website',\n        rating_below_peers: 'rating below nearby peers',\n        negative_reviews: 'heavy tail of 1 and 2 star reviews',\n        few_reviews: 'far fewer reviews than nearby peers',\n        few_photos: 'almost no photos',\n        no_hours: 'no opening hours',\n        single_category: 'a single category',\n        no_services: 'no services listed',\n      };\n      const m = {};\n      for (const r of rows) for (const k of r.gap_keys || []) m[k] = (m[k] || 0) + 1;\n      return Object.entries(m).sort((a, b) => b[1] - a[1]).slice(0, 5)\n        .map(([key, count]) => ({ name: GAP_NAMES[key] || key, count }));\n    })(),\n    common_complaints: tally('complaint_themes').slice(0, 5),\n    prospects: rows,\n  },\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "t6-brief",
      "name": "Write Prospect Brief",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "onError": "continueRegularOutput",
      "position": [
        4832,
        460
      ],
      "parameters": {
        "text": "=You are a local marketing consultant reviewing this week's prospect list before an outreach round.\n\nCity: {{ $json.city }}\nCategories searched: {{ $json.categories }}\nBusiness profiles audited: {{ $json.audited_count }}\nProspects that made the list: {{ $json.prospect_count }}\nUnclaimed listings among them: {{ $json.unclaimed_count }}\nProspects with no website: {{ $json.no_website_count }}\nThe gaps that came up most often: {{ JSON.stringify($json.common_gaps) }}\nComplaint themes across their reviews: {{ JSON.stringify($json.common_complaints) }}\nThe prospects themselves: {{ JSON.stringify($json.prospects.map(p => ({ name: p.name, category: p.category, score: p.gap_score, rating: p.rating, reviews: p.reviews, gaps: p.gaps, themes: p.complaint_themes }))) }}\n\nWrite 4 to 6 sentences covering: which of these is worth calling first and why, what the list has in common, and which gap is the easiest thing to fix for whoever takes the work.\n\nStick to what the numbers say. If only a few businesses made the list, say the sample is small rather than describing a pattern. Never state that a business is losing customers or money - that is not in this data. Plain sentences, no bullet points, no headings.",
        "batching": {},
        "promptType": "define"
      },
      "typeVersion": 1.9
    },
    {
      "id": "t6-llm2",
      "name": "OpenAI Brief Writer",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "onError": "continueRegularOutput",
      "position": [
        4840,
        700
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-5.6-terra"
        },
        "options": {
          "temperature": 0.3
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "t6-report",
      "name": "Build Report",
      "type": "n8n-nodes-base.code",
      "position": [
        5356,
        460
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const data = $('Rank Prospects').first().json;\n// single-item input: the brief chain is fed one aggregated item and answers with\n// one block of prose, so there is no second item to lose here.\nconst brief = String($input.first().json.text || $input.first().json.output || '').trim();\n\nconst plural = (n, word) => n + ' ' + word + (n === 1 ? '' : 's');\n\n// One row per prospect, so the sheet doubles as the do-not-repeat list the next\n// run reads back.\nconst rows = data.prospects.map((p) => ({\n  logged_at: data.scanned_at,\n  city: data.city,\n  place_id: p.place_id,\n  name: p.name,\n  category: p.category,\n  gap_score: p.gap_score,\n  rating: p.rating,\n  reviews: p.reviews,\n  peer_rating: p.peer_rating || '',\n  peer_reviews: p.peer_reviews || '',\n  negative_pct: p.negative_pct,\n  claimed: p.claimed ? 'yes' : 'no',\n  photos: p.photos,\n  phone: p.phone,\n  website: p.website,\n  address: p.address,\n  maps_url: p.maps_url,\n  gaps: p.gaps.join('; '),\n  complaint_themes: p.complaint_themes.join('; '),\n  review_sentiment: p.review_sentiment,\n  pitch: p.pitch,\n}));\n\nconst lines = [];\nlines.push(`*Local profile gaps - ${data.city}*`);\nlines.push(\n  `${plural(data.audited_count, 'profile')} audited, ` +\n  `${plural(data.prospect_count, 'prospect')} worth contacting.`\n);\n\nlines.push('');\ndata.prospects.slice(0, 8).forEach((p, i) => {\n  lines.push(`${i + 1}. *${p.name}* (${p.category}) - score ${p.gap_score}`);\n  lines.push(`   ${p.gaps.slice(0, 3).join(', ')}`);\n  if (p.complaint_themes.length) {\n    lines.push(`   Reviews mention: ${p.complaint_themes.join(', ')}`);\n  }\n  lines.push(`   ${p.phone || 'no phone listed'} | ${p.maps_url}`);\n});\n\nif (data.common_complaints.length) {\n  lines.push('');\n  lines.push('*Complaints across the list*');\n  lines.push(data.common_complaints.map((c) => `${c.name} (${c.count})`).join(', '));\n}\n\nif (brief) {\n  lines.push('');\n  lines.push(brief);\n}\n\n// One item carrying both payloads. Split Out turns `rows` into one item per\n// prospect for the sheet; the Slack node runs once and reads `message` from\n// here. Emitting the message as its own item instead would append it to the\n// sheet as a row, and post the digest once per prospect.\nreturn [{ json: { message: lines.join('\\n'), rows, row_count: rows.length } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "t6-split",
      "name": "Split Out Prospect Rows",
      "type": "n8n-nodes-base.splitOut",
      "position": [
        5596,
        460
      ],
      "parameters": {
        "options": {},
        "fieldToSplitOut": "rows"
      },
      "typeVersion": 1
    },
    {
      "id": "t6-sheet",
      "name": "Log Prospects To Sheet",
      "type": "n8n-nodes-base.googleSheets",
      "onError": "continueRegularOutput",
      "position": [
        5836,
        460
      ],
      "parameters": {
        "columns": {
          "value": {},
          "mappingMode": "autoMapInputData",
          "matchingColumns": []
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Prospects"
        },
        "documentId": {
          "__rl": true,
          "mode": "url",
          "value": "={{ $('Set Search Config').first().json.sheet_url }}"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "t6-slack",
      "name": "Post Digest To Slack",
      "type": "n8n-nodes-base.slack",
      "onError": "continueRegularOutput",
      "position": [
        6076,
        460
      ],
      "parameters": {
        "text": "={{ $('Build Report').first().json.message }}",
        "select": "channel",
        "resource": "message",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $('Set Search Config').first().json.slack_channel }}"
        },
        "operation": "post",
        "otherOptions": {}
      },
      "executeOnce": true,
      "typeVersion": 2.3
    }
  ],
  "settings": {
    "executionOrder": "v1"
  },
  "connections": {
    "Build Report": {
      "main": [
        [
          {
            "node": "Split Out Prospect Rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Rank Prospects": {
      "main": [
        [
          {
            "node": "Write Prospect Brief",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Profiles Ready?": {
      "main": [
        [
          {
            "node": "Download Business Profiles",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Wait For Snapshot",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Search Config": {
      "main": [
        [
          {
            "node": "Build Discovery Input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait For Snapshot": {
      "main": [
        [
          {
            "node": "Check Snapshot Progress",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Any New Prospects?": {
      "main": [
        [
          {
            "node": "Read Complaint Themes",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Nothing New This Week",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Score Profile Gaps": {
      "main": [
        [
          {
            "node": "Read Reported Businesses",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI Brief Writer": {
      "ai_languageModel": [
        [
          {
            "node": "Write Prospect Brief",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI Review Reader": {
      "ai_languageModel": [
        [
          {
            "node": "Read Complaint Themes",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Select New Prospects": {
      "main": [
        [
          {
            "node": "Any New Prospects?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Weekly Prospect Scan": {
      "main": [
        [
          {
            "node": "Set Search Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Write Prospect Brief": {
      "main": [
        [
          {
            "node": "Build Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Discovery Input": {
      "main": [
        [
          {
            "node": "Discover Businesses With Bright Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Complaint Themes": {
      "main": [
        [
          {
            "node": "Rank Prospects",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log Prospects To Sheet": {
      "main": [
        [
          {
            "node": "Post Digest To Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Snapshot Progress": {
      "main": [
        [
          {
            "node": "Evaluate Scrape Progress",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split Out Prospect Rows": {
      "main": [
        [
          {
            "node": "Log Prospects To Sheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Discovery Accepted": {
      "main": [
        [
          {
            "node": "Wait For Snapshot",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Evaluate Scrape Progress": {
      "main": [
        [
          {
            "node": "Profiles Ready?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Reported Businesses": {
      "main": [
        [
          {
            "node": "Select New Prospects",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Download Business Profiles": {
      "main": [
        [
          {
            "node": "Score Profile Gaps",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Discover Businesses With Bright Data": {
      "main": [
        [
          {
            "node": "Check Discovery Accepted",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}