{
  "id": "0cz5L9CiI3gZ7XOg",
  "name": "Discover Instagram posts by hashtag with engagement scoring",
  "tags": [],
  "nodes": [
    {
      "id": "a942a075-e818-47e2-95a2-b765943683d0",
      "name": "Daily check for trending posts",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -1536,
        96
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "hours",
              "hoursInterval": 24
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "a7dba99a-7510-41a6-ad6a-6de897d103a7",
      "name": "Load monitoring hashtags",
      "type": "n8n-nodes-base.code",
      "position": [
        -1312,
        96
      ],
      "parameters": {
        "jsCode": "// \u2500\u2500\u2500 CONFIGURE YOUR HASHTAGS HERE \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst HASHTAGS = [ \n  'retrogames', \n  'ps2games'\n];\n\nconst MAX_POSTS_PER_TAG = 10; // how many posts to fetch per hashtag\n\nreturn HASHTAGS.map(tag => ({\n  json: { hashtag: tag, maxPosts: MAX_POSTS_PER_TAG }\n}));"
      },
      "typeVersion": 2
    },
    {
      "id": "aa5ea33e-fbe8-42f5-a280-b563b32607df",
      "name": "Scrape Instagram posts via Apify API",
      "type": "n8n-nodes-base.httpRequest",
      "notes": "NOTE: Instagram's private API blocks unauthenticated requests.\nFor production use, replace this node with:\n  \u2022 Apify Instagram Hashtag Scraper actor\n  \u2022 RapidAPI Instagram Scraper\n  \u2022 Your own authenticated session cookies\nSee README comment in 'Parse Posts' node for cookie auth approach.",
      "position": [
        -1088,
        96
      ],
      "parameters": {
        "url": "=https://api.apify.com/v2/acts/apify~instagram-hashtag-scraper/run-sync-get-dataset-items?token={{ env.APIFY_TOKEN }}",
        "options": {
          "timeout": 15000
        },
        "jsonBody": "={\n  \"hashtags\": [\"{{ $json.hashtag }}\"],\n  \"resultsLimit\": 10,\n  \"proxy\": {\n    \"useApifyProxy\": true,\n    \"apifyProxyGroups\": [\"RESIDENTIAL\"]\n  }\n}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "da04c938-17fb-408b-909b-749b9e8a41e4",
      "name": "Extract and normalize post data",
      "type": "n8n-nodes-base.code",
      "position": [
        -816,
        96
      ],
      "parameters": {
        "jsCode": "// 1. Get ALL incoming items that fetched from the API\nconst allInputItems = $input.all();\nconst hashtag = $('Set Hashtags').item.json.hashtag; // Fallback fallback tag tracking\n\nlet allParsedPosts = [];\n\n// 2. Loop through each API result item safely\nfor (const item of allInputItems) {\n  const raw = item.json;\n  \n  let posts = [];\n  if (Array.isArray(raw)) {\n    posts = raw;\n  } else if (Array.isArray(raw?.items)) {\n    posts = raw.items;\n  } else if (Array.isArray(raw?.data)) {\n    posts = raw.data;\n  } else if (Array.isArray(raw?.results)) {\n    posts = raw.results;\n  } else if (raw && typeof raw === 'object' && !Array.isArray(raw)) {\n    posts = [raw];\n  }\n\n  if (posts.length === 0) {\n    continue;\n  }\n\n  // 3. Map individual entries to your formatting\n  const mapped = posts.map(post => ({\n    json: {\n      id:        post.id || post.shortCode || post.pk || '',\n      shortcode: post.shortCode || post.id || '',\n      url:       post.url || `https://www.instagram.com/p/${post.shortCode || post.id}/`,\n      thumbnail: post.displayUrl || post.thumbnailSrc || '',\n      caption:   (post.caption || post.accessibility_caption || '').slice(0, 200),\n      likes:     Number(post.likesCount || 0),\n      comments:  Number(post.commentsCount || 0),\n      saves:     Number(post.savesCount || 0),\n      reposts:   Number(post.videoViewCount || post.videoPlayCount || 0),\n      timestamp: post.timestamp || '',\n      author:    post.ownerUsername || post.ownerFullName || 'unknown',\n      hashtag:   hashtag, // Maps back to the workflow contextual tag\n      type:      post.type || post.productType || 'image'\n    }\n  }));\n\n  allParsedPosts.push(...mapped);\n}\n\nif (allParsedPosts.length === 0) {\n  return [{ json: { _empty: true, hashtag, _debug_raw: \"No items parsed from any run.\" } }];\n}\n\nreturn allParsedPosts;"
      },
      "typeVersion": 2
    },
    {
      "id": "e0c46425-fd04-408f-a967-8878eb8c08e8",
      "name": "Calculate engagement score and rank",
      "type": "n8n-nodes-base.code",
      "position": [
        -368,
        96
      ],
      "parameters": {
        "jsCode": "// \u2500\u2500\u2500 ENGAGEMENT SCORE FORMULA \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Weights reflect content signal value:\n//   reposts  = 4  (strongest signal \u2014 someone shares it)\n//   comments = 3  (high intent \u2014 someone typed)\n//   saves    = 2  (bookmarks = future intent)\n//   likes    = 1  (lowest friction)\n\nconst W = { reposts: 4, comments: 3, saves: 2, likes: 1 };\n\nconst items = $input.all();\n\n// Deduplicate by post ID (across hashtags)\nconst seen = new Set();\nconst unique = items.filter(item => {\n  const id = item.json.id || item.json.shortcode;\n  if (!id || seen.has(id)) return false;\n  seen.add(id);\n  return !item.json._empty;\n});\n\n// Score each post\nconst scored = unique.map(item => {\n  const p = item.json;\n  const score = Math.round(\n    (p.likes    || 0) * W.likes    +\n    (p.comments || 0) * W.comments +\n    (p.saves    || 0) * W.saves    +\n    (p.reposts  || 0) * W.reposts\n  );\n\n  // Normalise timestamp\n  let postedAt = '';\n  if (p.timestamp) {\n    const ts = typeof p.timestamp === 'number' ? p.timestamp * 1000 : new Date(p.timestamp).getTime();\n    postedAt = new Date(ts).toISOString();\n  }\n\n  return {\n    json: {\n      ...p,\n      score,\n      postedAt,\n      scannedAt: new Date().toISOString()\n    }\n  };\n});\n\n// Sort descending by score\nscored.sort((a, b) => b.json.score - a.json.score);\n\n// Return top 10\nreturn scored.slice(0, 10);"
      },
      "typeVersion": 2
    },
    {
      "id": "8eab1012-81a5-412f-9c87-834e30df2b4e",
      "name": "Format HTML report for Telegram",
      "type": "n8n-nodes-base.code",
      "position": [
        0,
        0
      ],
      "parameters": {
        "jsCode": "// Build a compact HTML report for Telegram (parse_mode: HTML)\nconst posts = $input.all();\nconst now = new Date().toUTCString();\n\nconst hashtags = [...new Set(posts.map(p => p.json.hashtag))]\n  .map(h => `#${h}`).join(' ');\n\nconst rows = posts.map((item, i) => {\n  const p = item.json;\n  const medal = ['\ud83e\udd47','\ud83e\udd48','\ud83e\udd49'][i] || `${i+1}.`;\n  const caption = p.caption ? p.caption.replace(/</g,'&lt;').replace(/>/g,'&gt;').slice(0,80) + '\u2026' : '(no caption)';\n  const postedDate = p.postedAt ? new Date(p.postedAt).toLocaleDateString('en-GB', { day:'numeric', month:'short' }) : '\u2014';\n\n  return (\n    `${medal} <a href=\"${p.url}\">@${p.author}</a>  <b>Score: ${p.score.toLocaleString()}</b>\\n` +\n    `   \ud83d\udc4d ${(p.likes||0).toLocaleString()}  \ud83d\udcac ${(p.comments||0).toLocaleString()}  \ud83d\udd16 ${(p.saves||0).toLocaleString()}  \ud83d\udd01 ${(p.reposts||0).toLocaleString()}\\n` +\n    `   \ud83d\udcc5 ${postedDate}  |  ${p.type || 'post'}\\n` +\n    `   <i>${caption}</i>\\n`\n  );\n}).join('\\n');\n\nconst message =\n  `<b>\ud83d\udcca Instagram Engagement Report</b>\\n` +\n  `${hashtags}\\n` +\n  `<i>Generated: ${now}</i>\\n\\n` +\n  rows +\n  `\\n<i>Top 10 by weighted score (reposts\u00d74 \u00b7 comments\u00d73 \u00b7 saves\u00d72 \u00b7 likes\u00d71)</i>`;\n\nreturn [{ json: { message } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "21593d8a-f9bc-48b3-90da-4dec77a5f0f4",
      "name": "Send engagement report to Telegram",
      "type": "n8n-nodes-base.telegram",
      "position": [
        208,
        0
      ],
      "parameters": {
        "text": "={{ $json.message }}",
        "chatId": "={{ env.TELEGRAM_CHAT_ID }}",
        "additionalFields": {
          "parse_mode": "HTML",
          "disable_web_page_preview": false
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "020248c9-ec99-4bd7-9cd7-648c5798c61a",
      "name": "Save results to Airtable database",
      "type": "n8n-nodes-base.airtable",
      "position": [
        0,
        192
      ],
      "parameters": {
        "base": {
          "__rl": true,
          "mode": "id",
          "value": "={{ env.AIRTABLE_BASE_ID }}"
        },
        "table": {
          "__rl": true,
          "mode": "list",
          "value": "={{ env.AIRTABLE_TABLE_ID }}",
          "cachedResultName": "Table 1"
        },
        "columns": {
          "value": {},
          "schema": [
            {
              "id": "id",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": true,
              "required": false,
              "displayName": "id",
              "defaultMatch": true
            },
            {
              "id": "url",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "url",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "author",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "author",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "hashtag",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "hashtag",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "caption",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "caption",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "likes",
              "type": "number",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "likes",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "comments",
              "type": "number",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "comments",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "saves",
              "type": "number",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "saves",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "reposts",
              "type": "number",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "reposts",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "score",
              "type": "number",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "score",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "postedAt",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "postedAt",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "scannedAt",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "scannedAt",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "shortcode",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "shortcode",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "thumbnail",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "thumbnail",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "timestamp",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "timestamp",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "type",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "type",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "autoMapInputData",
          "matchingColumns": [
            "id"
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "upsert"
      },
      "typeVersion": 2.1
    },
    {
      "id": "8b092c47-9a1c-4c28-9c24-8914f07144bf",
      "name": "Remove empty API responses",
      "type": "n8n-nodes-base.filter",
      "position": [
        -592,
        96
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "no-empty",
              "operator": {
                "type": "boolean",
                "operation": "notEquals"
              },
              "leftValue": "={{ $json._empty }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "132178f0-4eb2-425e-8423-0ff1cd5ec49a",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -2096,
        -144
      ],
      "parameters": {
        "width": 432,
        "height": 736,
        "content": "## Discover Instagram posts by hashtag with engagement scoring\n\n### How it works\n\n1. Triggers a daily schedule to check for trending Instagram posts by hashtag.\n2. Loads the configured hashtags to monitor.\n3. Scrapes Instagram posts using the Apify API.\n4. Extracts and processes the post data, removing empty responses.\n5. Calculates engagement scores, then sends a report to Telegram and saves data to Airtable.\n\n### Setup steps\n\n- [ ] Configure the daily schedule trigger to set the desired time for checking posts.\n- [ ] Set hashtags in the 'Load monitoring hashtags' node.\n- [ ] Ensure Apify API credentials are configured in the 'Scrape Instagram posts via Apify API' node.\n- [ ] Set up Telegram credentials in the 'Send engagement report to Telegram' node.\n- [ ] Set Airtable credentials in the 'Save results to Airtable database' node.\n\n### Customization\n\nYou can adjust the engagement score formula in the 'Calculate engagement score and rank' node."
      },
      "typeVersion": 1
    },
    {
      "id": "6a809776-552f-4e62-96ce-b7e03bcf6498",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1600,
        -48
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 320,
        "content": "## Scheduled hashtag check\n\nTriggers a daily schedule to load hashtags for monitoring."
      },
      "typeVersion": 1
    },
    {
      "id": "12106d24-09bd-49a9-b23f-ab06478859a0",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1168,
        -48
      ],
      "parameters": {
        "color": 7,
        "width": 288,
        "height": 320,
        "content": "## Scrape Instagram data\n\nFetches Instagram posts using Apify API after loading hashtags."
      },
      "typeVersion": 1
    },
    {
      "id": "bbfd204d-18a0-4c7f-a32a-687d92391413",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -864,
        -48
      ],
      "parameters": {
        "color": 7,
        "width": 432,
        "height": 320,
        "content": "## Process and filter data\n\nExtracts and normalizes post data, then removes empty responses."
      },
      "typeVersion": 1
    },
    {
      "id": "882b9c27-1639-4aef-8b07-e96ac3ee26fb",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -416,
        -128
      ],
      "parameters": {
        "color": 7,
        "width": 816,
        "height": 528,
        "content": "## Calculate and save scores\n\nCalculates engagement scores, formats HTML reports, sends to Telegram, and saves to Airtable."
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "availableInMCP": false,
    "executionOrder": "v1"
  },
  "versionId": "094a591e-3f7a-4495-b450-2c6f9e0f3ae1",
  "connections": {
    "Load monitoring hashtags": {
      "main": [
        [
          {
            "node": "Scrape Instagram posts via Apify API",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Remove empty API responses": {
      "main": [
        [
          {
            "node": "Calculate engagement score and rank",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Daily check for trending posts": {
      "main": [
        [
          {
            "node": "Load monitoring hashtags",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract and normalize post data": {
      "main": [
        [
          {
            "node": "Remove empty API responses",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format HTML report for Telegram": {
      "main": [
        [
          {
            "node": "Send engagement report to Telegram",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Calculate engagement score and rank": {
      "main": [
        [
          {
            "node": "Format HTML report for Telegram",
            "type": "main",
            "index": 0
          },
          {
            "node": "Save results to Airtable database",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Scrape Instagram posts via Apify API": {
      "main": [
        [
          {
            "node": "Extract and normalize post data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}