{
  "id": "q5qGINfIHOWWBr1V",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Google Trends to slack",
  "tags": [],
  "nodes": [
    {
      "id": "7986d495-fd51-44f1-95db-f9f5c67c4662",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        448,
        -256
      ],
      "parameters": {
        "width": 480,
        "height": 784,
        "content": "## Automated Google Trends to slack\n\n### How it works\n\nThe workflow runs on a defined schedule and fetches data from Google Trends.\nIt processes and filters the fetched items based on specific conditions.\nFiltered items are aggregated and passed to a Gemini AI Agent for analysis.\nThe AI structures the analysis, which is then formatted by a code node.\nFinally, the formatted analysis is sent as a message to a designated Slack channel.\n\n### Setup steps\n\n- [ ] Configure the Schedule Trigger for your desired interval.\n- [ ] Authenticate the Google Gemini Chat Model with your API credentials.\n- [ ] Set up your Slack credentials and specify the target channel in the Send a message node.\n\n### Customization\n\nYou can customize the prompt in the AI Agent to change how the trends are analyzed, or adjust the filter criteria in the If node."
      },
      "typeVersion": 1
    },
    {
      "id": "4090da5b-8c37-486f-8393-6dc9fc09221e",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1008,
        -256
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 304,
        "content": "## Trigger and fetch data\n\nTriggers on a schedule and fetches Google Trends data"
      },
      "typeVersion": 1
    },
    {
      "id": "76a327cf-002c-45ef-a02b-e5cb4eb93746",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1456,
        -240
      ],
      "parameters": {
        "color": 7,
        "width": 864,
        "height": 272,
        "content": "## Filter and aggregate trends\n\nParses the trend data, filters out irrelevant items, and aggregates the results"
      },
      "typeVersion": 1
    },
    {
      "id": "f4cb01b3-8286-4d12-93de-4ec6120fc0b0",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2352,
        -256
      ],
      "parameters": {
        "color": 7,
        "width": 352,
        "height": 512,
        "content": "## Analyze with AI\n\nPasses aggregated trends to an AI agent to analyze and structure the output"
      },
      "typeVersion": 1
    },
    {
      "id": "3dc07c8e-26fd-414c-b976-032c37b8d2c9",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2736,
        -256
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 304,
        "content": "## Send Slack notification\n\nFormats the AI response and sends a notification to a Slack channel"
      },
      "typeVersion": 1
    },
    {
      "id": "d35893b1-37b5-4749-9f0d-d4575a790eca",
      "name": "Fetch Google Trends Data",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1280,
        -128
      ],
      "parameters": {
        "url": "https://trends.google.com/trending?geo=US&hl=en-US&hours=168&status=active",
        "options": {}
      },
      "typeVersion": 4.4
    },
    {
      "id": "8cecc5ce-99e4-4efc-a8ef-afdc70603d5d",
      "name": "Parse Trend Data",
      "type": "n8n-nodes-base.code",
      "position": [
        1504,
        -128
      ],
      "parameters": {
        "jsCode": "// ============================================================\n// Google Trends Extractor \u2014 Bracket-Balanced Parser\n// No regex for data extraction; walks chars to find JSON\n// ============================================================\n\nconst RAW_HTML = $input.first().json.data;\n\nif (typeof RAW_HTML !== 'string') {\n  throw new Error('Expected string HTML. Got: ' + typeof RAW_HTML);\n}\n\n// -----------------------------------------------------------\n// Bracket-balanced extractor\n// Walks from a '[' or '{' and finds the matching close char,\n// respecting strings and escape sequences. Then tries JSON.parse.\n// -----------------------------------------------------------\nfunction extractBalancedJSON(html, start) {\n  const open = html[start];\n  if (open !== '[' && open !== '{') return null;\n\n  let depth = 0;\n  let inStr = false;\n  let esc = false;\n  let strChar = '';\n  const limit = Math.min(start + 6_000_000, html.length);\n\n  for (let i = start; i < limit; i++) {\n    const c = html[i];\n    if (esc)    { esc = false; continue; }\n    if (inStr) {\n      if (c === '\\\\')     { esc = true; continue; }\n      if (c === strChar)  { inStr = false; }\n      continue;\n    }\n    if (c === '\"' || c === \"'\") { inStr = true; strChar = c; continue; }\n    if (c === '[' || c === '{') { depth++; continue; }\n    if (c === ']' || c === '}') {\n      depth--;\n      if (depth === 0) {\n        const raw = html.slice(start, i + 1);\n        // Strip Google's %.@. wire-format prefix if present\n        const clean = raw.startsWith('%.@.') ? raw.slice(4) : raw;\n        try { return JSON.parse(clean); } catch { return null; }\n      }\n    }\n  }\n  return null;\n}\n\n// -----------------------------------------------------------\n// Walk the HTML for every AF_initDataCallback occurrence,\n// locate the data: key, skip optional function(){return ...}\n// wrapper, then extract with the balanced walker.\n// -----------------------------------------------------------\nfunction extractAllCallbacks(html) {\n  const results = [];\n  const marker = 'AF_initDataCallback';\n  let pos = 0;\n\n  while (true) {\n    const cbPos = html.indexOf(marker, pos);\n    if (cbPos === -1) break;\n    pos = cbPos + marker.length;\n\n    const dataKeyPos = html.indexOf('data:', cbPos);\n    if (dataKeyPos === -1 || dataKeyPos - cbPos > 600) continue;\n\n    let dataStart = dataKeyPos + 5;\n    // Skip whitespace\n    while (dataStart < html.length && ' \\t\\n\\r'.includes(html[dataStart])) dataStart++;\n    // Skip function(){return ...} wrapper if present\n    if (html.slice(dataStart, dataStart + 8) === 'function') {\n      const retIdx = html.indexOf('return', dataStart);\n      if (retIdx !== -1 && retIdx - dataStart < 60) {\n        dataStart = retIdx + 6;\n        while (dataStart < html.length && ' \\t\\n\\r'.includes(html[dataStart])) dataStart++;\n      }\n    }\n\n    const payload = extractBalancedJSON(html, dataStart);\n    if (payload !== null) results.push(payload);\n  }\n\n  return results;\n}\n\n// -----------------------------------------------------------\n// Recursively search parsed payloads for the trends rows.\n// Signature: an array of arrays where each row has\n//   - at least 6 elements\n//   - row[0] is a non-empty string (the query)\n// Checks first 3 rows to confirm the pattern.\n// -----------------------------------------------------------\nfunction findTrendsArray(node, depth = 0) {\n  if (depth > 10 || !node || typeof node !== 'object') return null;\n\n  if (Array.isArray(node) && node.length >= 3) {\n    const looksLikeTrends = node\n      .slice(0, Math.min(3, node.length))\n      .every(row => Array.isArray(row) && row.length >= 6 && typeof row[0] === 'string' && row[0].length > 0);\n    if (looksLikeTrends) return node;\n  }\n\n  const children = Array.isArray(node) ? node : Object.values(node);\n  for (const child of children) {\n    if (child && typeof child === 'object') {\n      const found = findTrendsArray(child, depth + 1);\n      if (found) return found;\n    }\n  }\n  return null;\n}\n\n// -----------------------------------------------------------\n// Run extraction\n// -----------------------------------------------------------\nconst payloads = extractAllCallbacks(RAW_HTML);\n\nif (payloads.length === 0) {\n  const cbCount = (RAW_HTML.match(/AF_initDataCallback/g) || []).length;\n  throw new Error(\n    `No parseable AF_initDataCallback payloads found. ` +\n    `Saw ${cbCount} callback marker(s) in ${RAW_HTML.length} chars. ` +\n    `HTML snippet (first 500 chars): ${RAW_HTML.slice(0, 500)}`\n  );\n}\n\nlet trendsRows = null;\nfor (const payload of payloads) {\n  trendsRows = findTrendsArray(payload);\n  if (trendsRows) break;\n}\n\nif (!trendsRows) {\n  throw new Error(\n    `Parsed ${payloads.length} callback payload(s) but no trends rows found inside. ` +\n    `Top-level types: ${payloads.map(p => Array.isArray(p) ? 'array['+p.length+']' : 'object').join(', ')}`\n  );\n}\n\n// -----------------------------------------------------------\n// Map raw rows \u2192 clean named objects\n//\n// Row index reference:\n//   [0]  query string\n//   [1]  entity/topic name (or null)\n//   [2]  country code\n//   [3]  [startTimestamp]  \u2014 unix seconds in a 1-item array\n//   [4]  [endTimestamp]    \u2014 unix seconds in a 1-item array\n//   [6]  search volume estimate\n//   [8]  trend/breakout score\n//   [9]  related queries   \u2014 string[]\n//   [10] category IDs      \u2014 number[]\n//   [12] normalized query\n// -----------------------------------------------------------\nfunction toISO(ts)          { return ts ? new Date(ts * 1000).toISOString() : null; }\nfunction safe(arr, i, fb)   { return Array.isArray(arr) && arr[i] != null ? arr[i] : (fb ?? null); }\nfunction tsFromWrapped(arr) { return Array.isArray(arr) && arr[0] != null ? arr[0] : null; }\n\nreturn trendsRows.map(row => ({\n  json: {\n    query:           safe(row, 0, ''),\n    entity:          safe(row, 1),\n    country:         safe(row, 2, 'US'),\n    startTime:       toISO(tsFromWrapped(row[3])),\n    endTime:         toISO(tsFromWrapped(row[4])),\n    searchVolume:    safe(row, 6, 0),\n    trendScore:      safe(row, 8, 0),\n    relatedQueries:  Array.isArray(row[9])  ? row[9]  : [],\n    categoryIds:     Array.isArray(row[10]) ? row[10] : [],\n    normalizedQuery: safe(row, 12) || safe(row, 0, ''),\n  }\n}));"
      },
      "typeVersion": 2
    },
    {
      "id": "a98e6f04-d998-4568-83c8-2feca7835fa8",
      "name": "If Trend Active",
      "type": "n8n-nodes-base.if",
      "position": [
        1728,
        -128
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "730e027e-119c-4a08-9028-e515fdfb71fd",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.endTime === null }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "60862c74-b6cd-42c3-b23c-1dd45a1956c9",
      "name": "Prepare AI Input Data",
      "type": "n8n-nodes-base.set",
      "position": [
        2176,
        -128
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "9ae4ac91-d6c1-410f-ad6f-3d6ce1cb8171",
              "name": "combined things",
              "type": "string",
              "value": "={{ $json.data.map(item => item.query).join(', ') }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "6245af17-90e5-4876-8ffe-00ce1f059c26",
      "name": "Aggregate Trend Results",
      "type": "n8n-nodes-base.aggregate",
      "position": [
        1952,
        -128
      ],
      "parameters": {
        "options": {},
        "aggregate": "aggregateAllItemData"
      },
      "typeVersion": 1
    },
    {
      "id": "9a4fc4cf-8f36-4cd8-8605-db3755ae22b1",
      "name": "Trend Summarizer Agent",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        2400,
        -128
      ],
      "parameters": {
        "text": "=INPUT TERMS:\n{{ $json['combined things'] }}",
        "options": {
          "systemMessage": "You are a highly restrictive trend-filtering assistant for an automation agency (blankarray). The agency builds n8n workflows exclusively for **brick-and-mortar retail**, **physical store operations**, and **on-the-ground warehouse/logistics**.\n\nYour sole job is to evaluate a comma-separated string of trending search terms and extract ONLY the items that are explicitly tied to the physical retail and logistics niche.\n\nINCLUSION CRITERIA (Must strictly match one of these):\n1. Physical Retail & Store Ops: Brick-and-mortar formats (grocery, discount, big box, specialty), foot traffic, checkout ops, staffing/shift management, shelf inventory, physical merchandising.\n2. Warehousing & Logistics: Distribution centers, on-premise fulfillment, supply chain ops, warehouse robotics, palletizing, last-mile physical delivery fleets.\n3. In-Store Technology: Point of Sale (POS) hardware/systems, Self-Checkout (SCO), RFID, electronic shelf labels (ESL), physical loss prevention/shrink, smart carts, physical store security.\n4. Physical Retail Business News: Brick-and-mortar bankruptcies, physical store closures/openings, retail real estate expansions, warehouse unionization.\n\nEXCLUSION CRITERIA (Instantly reject these):\n- Pure E-commerce & D2C (Shopify, WooCommerce, dropshipping, digital storefronts) UNLESS explicitly tied to physical stores (e.g., BOPIS - Buy Online, Pick Up In Store).\n- Digital marketing, SEO, social media algorithms, or general SaaS.\n- General consumer tech, gadgets, or software unrelated to store/warehouse operations.\n- Sports, entertainment, pop culture, politics, weather, or celebrities.\n- General finance or macroeconomics (unless directly detailing physical retail sales/foot traffic).\n\nINSTRUCTIONS:\n- For each valid match, provide a brief `reason` explaining its direct connection to physical stores or warehouses\n- If a term is borderline or vague (e.g., \"AI integration\" or \"supply and demand\"), REJECT IT. Only accept clear, unambiguous physical retail/logistics terms.\n- Return ONLY valid JSON in the exact structure provided below. Do not include markdown formatting fences, preambles, or post-response explanations.\n\nIf no items are relevant, return exactly: {\"matches\": [], \"match_count\": 0}"
        },
        "promptType": "define",
        "hasOutputParser": true
      },
      "typeVersion": 3.1
    },
    {
      "id": "d6ba6902-1792-4fae-933d-ef001d31eb0d",
      "name": "Parse AI Output",
      "type": "@n8n/n8n-nodes-langchain.outputParserStructured",
      "position": [
        2544,
        96
      ],
      "parameters": {
        "jsonSchemaExample": "{\n  \"matches\": [\n    {\n      \"term\": \"string\",\n      \"reason\": \"string (Why it strictly fits physical retail/warehousing)\"\n    }\n  ],\n  \"match_count\": 0\n}\n"
      },
      "typeVersion": 1.3
    },
    {
      "id": "d04c675b-672c-471e-86d2-54faf71c2f58",
      "name": "Gemini Chat Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini",
      "position": [
        2416,
        96
      ],
      "parameters": {
        "options": {},
        "modelName": "models/gemini-3.1-flash-lite"
      },
      "credentials": {
        "googlePalmApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "3df3d00c-bebd-405e-ace6-79b5b37f3703",
      "name": "Format Summary for Slack",
      "type": "n8n-nodes-base.code",
      "position": [
        2784,
        -128
      ],
      "parameters": {
        "jsCode": "// 1. Grab ALL trend items that passed the 'If' node (not just the first)\nconst allTrends = $('If Trend Active').all().map(i => i.json);\n\n// 2. Grab the array of matches from the AI Agent's structured output\nconst matchesArray = $input.first().json.output?.matches || [];\n\nconst successfulMatches = [];\n\nfor (const item of matchesArray) {\n  const termString = String(item.term).toLowerCase();\n\n  // 3. Find which original trend this AI match actually corresponds to\n  const matchedTrend = allTrends.find(trend => {\n    const queryString = String(trend.query).toLowerCase();\n    return termString.includes(queryString) || queryString.includes(termString);\n  });\n\n  if (matchedTrend) {\n    successfulMatches.push({\n      term: item.term,\n      reason: item.reason,\n      original_query: matchedTrend.query,\n      original_data: matchedTrend\n    });\n  }\n}\n\n// 4. If nothing matched, still return a clean, single-item shape\nif (successfulMatches.length === 0) {\n  return [{\n    json: {\n      matches: [],\n      message: \"No physical retail or logistics trends stood out this week \u2014 nothing worth flagging.\"\n    }\n  }];\n}\n\n// 5. Helper to make big numbers readable (e.g. 100000 -> 100K)\nfunction formatVolume(n) {\n  if (n >= 1000000) return (n / 1000000).toFixed(n % 1000000 === 0 ? 0 : 1) + 'M';\n  if (n >= 1000) return (n / 1000).toFixed(n % 1000 === 0 ? 0 : 1) + 'K';\n  return String(n);\n}\n\n// 6. Build a conversational, Slack mrkdwn-formatted message\nconst lines = successfulMatches.map(m => {\n  const vol = formatVolume(m.original_data.searchVolume);\n  return `\u2022 *${m.original_query}* \u2014 ~${vol} searches this week (trend score: ${m.original_data.trendScore}). ${m.reason}`;\n});\n\nconst intro = successfulMatches.length === 1\n  ? `Found a trend this week worth a look \ud83d\udc40`\n  : `Found ${successfulMatches.length} trends this week worth a look \ud83d\udc40`;\n\nconst message = `${intro}\\n\\n${lines.join('\\n')}`;\n\n// 7. Return ONE item with both the raw matches and the message\nreturn [{\n  json: {\n    matches: successfulMatches,\n    message\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "3474ce26-9627-433f-a936-f93d2c881838",
      "name": "Post Summary to Slack",
      "type": "n8n-nodes-base.slack",
      "position": [
        3008,
        -128
      ],
      "parameters": {
        "text": "={{ $json.message }}",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "list",
          "value": "C0B8VH1M5PX",
          "cachedResultName": "general"
        },
        "otherOptions": {}
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.5
    },
    {
      "id": "269fe5f0-f91d-4c64-ba8c-caa87810258f",
      "name": "Every Week Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        1056,
        -128
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "weeks"
            }
          ]
        }
      },
      "typeVersion": 1.3
    }
  ],
  "active": true,
  "settings": {
    "binaryMode": "separate",
    "availableInMCP": false,
    "executionOrder": "v1"
  },
  "versionId": "e8144c02-19d0-424d-8117-fd7e13049037",
  "nodeGroups": [],
  "connections": {
    "If Trend Active": {
      "main": [
        [
          {
            "node": "Aggregate Trend Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse AI Output": {
      "ai_outputParser": [
        [
          {
            "node": "Trend Summarizer Agent",
            "type": "ai_outputParser",
            "index": 0
          }
        ]
      ]
    },
    "Parse Trend Data": {
      "main": [
        [
          {
            "node": "If Trend Active",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Gemini Chat Model": {
      "ai_languageModel": [
        [
          {
            "node": "Trend Summarizer Agent",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Every Week Trigger": {
      "main": [
        [
          {
            "node": "Fetch Google Trends Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare AI Input Data": {
      "main": [
        [
          {
            "node": "Trend Summarizer Agent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Trend Summarizer Agent": {
      "main": [
        [
          {
            "node": "Format Summary for Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Aggregate Trend Results": {
      "main": [
        [
          {
            "node": "Prepare AI Input Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Google Trends Data": {
      "main": [
        [
          {
            "node": "Parse Trend Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Summary for Slack": {
      "main": [
        [
          {
            "node": "Post Summary to Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}