{
  "id": "RgGP0o7EbesmgmbK",
  "name": "Export Telegram Channel Posts to Google Sheets with Apify",
  "tags": [],
  "nodes": [
    {
      "id": "70000000-0000-0000-4000-8000-000000000001",
      "name": "Manual Trigger",
      "type": "n8n-nodes-base.manualTrigger",
      "position": [
        -1440,
        176
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "70000000-0000-0000-4000-8000-000000000002",
      "name": "Daily 09:00 Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -1440,
        -16
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtHour": 9
            }
          ]
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "70000000-0000-0000-4000-8000-000000000003",
      "name": "1. Enter Telegram Channels",
      "type": "n8n-nodes-base.set",
      "position": [
        -1056,
        80
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "telegram-channels",
              "name": "channels",
              "type": "string",
              "value": "durov\ntelegram"
            },
            {
              "id": "telegram-per-channel",
              "name": "Maximum Posts Per Channel",
              "type": "number",
              "value": 10
            },
            {
              "id": "telegram-per-run",
              "name": "Maximum Posts Per Run",
              "type": "number",
              "value": 20
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "70000000-0000-0000-4000-8000-000000000004",
      "name": "Validate Channels and Build Actor Input",
      "type": "n8n-nodes-base.code",
      "position": [
        -816,
        80
      ],
      "parameters": {
        "jsCode": "const input = $input.first()?.json || {};\nconst channels = [...new Set(String(input.channels || '').split(/[\\n,]+/).map((value) => value.trim().replace(/^@/, '')).filter(Boolean))];\nconst maxPostsPerChannel = Math.max(1, Math.min(100, Math.floor(Number(input['Maximum Posts Per Channel']) || 10)));\nconst maximumPostsPerRun = Math.max(1, Math.min(500, Math.floor(Number(input['Maximum Posts Per Run']) || 20)));\nif (channels.length === 0) throw new Error('Enter at least one public Telegram channel handle.');\nif (channels.some((value) => !/^[A-Za-z0-9_]{3,64}$/.test(value))) throw new Error('Use public Telegram handles without t.me URLs.');\nreturn [{ json: {\n  channels, maximumPostsPerRun, maximumPostsPerChannel: maxPostsPerChannel,\n  intelligenceGoal: '', minimumRelevanceScore: 0, maxAlerts: 0,\n  actorInput: { channels, maxPostsPerChannel, maxPosts: maximumPostsPerRun, includeComments: false, includeCommentCount: true, includeMedia: true, includeRawHtmlSnippet: false }\n} }];"
      },
      "typeVersion": 2
    },
    {
      "id": "70000000-0000-0000-4000-8000-000000000005",
      "name": "2. Run Telegram Channel Posts Scraper",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -576,
        80
      ],
      "parameters": {
        "url": "https://api.apify.com/v2/acts/IXhSSJjFf5fz1XAir/runs",
        "method": "POST",
        "options": {
          "timeout": 310000,
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        },
        "jsonBody": "={{ $json.actorInput }}",
        "sendBody": true,
        "sendQuery": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "queryParameters": {
          "parameters": [
            {
              "name": "waitForFinish",
              "value": "300"
            }
          ]
        },
        "headerParameters": {
          "parameters": [
            {
              "name": "Accept-Encoding",
              "value": "identity"
            }
          ]
        }
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.3
    },
    {
      "id": "70000000-0000-0000-4000-8000-000000000006",
      "name": "Download Telegram Post Results",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -336,
        80
      ],
      "parameters": {
        "url": "=https://api.apify.com/v2/datasets/{{ $json.data.defaultDatasetId }}/items",
        "options": {
          "timeout": 60000,
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        },
        "sendQuery": true,
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "queryParameters": {
          "parameters": [
            {
              "name": "clean",
              "value": "true"
            },
            {
              "name": "limit",
              "value": "={{ $(\"Validate Channels and Build Actor Input\").first().json.maximumPostsPerRun }}"
            }
          ]
        }
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.3
    },
    {
      "id": "70000000-0000-0000-4000-8000-000000000007",
      "name": "Prepare Human-Readable Post Rows",
      "type": "n8n-nodes-base.code",
      "position": [
        -96,
        80
      ],
      "parameters": {
        "jsCode": "\nconst config = $('Validate Channels and Build Actor Input').first().json;\nconst payloads = $input.all().flatMap((item) => {\n  let value = item.json?.data ?? item.json;\n  if (typeof value === 'string') {\n    try { value = JSON.parse(value); } catch { throw new Error('Apify returned invalid JSON.'); }\n  }\n  return Array.isArray(value) ? value : [value];\n});\nconst sheetsEpochOffset = 25569;\nconst serial = (value) => {\n  const date = new Date(value || Date.now());\n  return Number.isFinite(date.getTime()) ? date.getTime() / 86400000 + sheetsEpochOffset : null;\n};\nconst numberOrNull = (value) => Number.isFinite(Number(value)) ? Number(value) : null;\nconst formula = (url, label) => url ? '=HYPERLINK(\"' + String(url).replace(/\"/g, '\"\"') + '\",\"' + String(label).replace(/\"/g, '\"\"') + '\")' : '';\nconst seen = new Set();\nconst rows = [];\nfor (const post of payloads) {\n  const channelHandle = String(post.channelHandle || '').trim().replace(/^@/, '').toLowerCase();\n  const postId = String(post.postId || '').trim();\n  const postUrl = String(post.postUrl || (channelHandle && postId ? 'https://t.me/' + channelHandle + '/' + postId : ''));\n  const text = String(post.text || '').trim();\n  if (!channelHandle || !postId || !postUrl) continue;\n  const itemKey = channelHandle + ':' + postId;\n  if (seen.has(itemKey)) continue;\n  seen.add(itemKey);\n  rows.push({ json: {\n    itemKey,\n    collectedAt: serial(post.scrapedAt),\n    channel: '@' + channelHandle,\n    channelHandle,\n    channelTitle: String(post.channelTitle || ''),\n    subscribers: numberOrNull(post.subscriberCount),\n    postDate: serial(post.date),\n    postDateIso: post.date ? new Date(post.date).toISOString() : '',\n    postLink: formula(postUrl, 'Open post'),\n    postUrl,\n    postId,\n    text: text.slice(0, 12000),\n    views: numberOrNull(post.views),\n    reactions: numberOrNull(post.reactionCount),\n    replies: numberOrNull(post.replyCount),\n    commentsScraped: numberOrNull(post.commentsScraped),\n    mediaCount: Array.isArray(post.mediaUrls) ? post.mediaUrls.length : 0,\n    links: Array.isArray(post.links) ? post.links.join('\\n') : '',\n    intelligenceGoal: config.intelligenceGoal,\n    minimumRelevanceScore: config.minimumRelevanceScore,\n    maxAlerts: config.maxAlerts\n  } });\n  if (rows.length >= config.maximumPostsPerRun) break;\n}\nif (rows.length === 0) throw new Error('No public Telegram posts were returned. Check the channel handles and date window.');\nreturn rows;"
      },
      "typeVersion": 2
    },
    {
      "id": "70000000-0000-0000-4000-8000-000000000008",
      "name": "3. Save Posts to Google Sheets",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        144,
        80
      ],
      "parameters": {
        "columns": {
          "value": {
            "Post": "={{ $json.postLink }}",
            "Text": "={{ $json.text }}",
            "Links": "={{ $json.links }}",
            "Views": "={{ $json.views }}",
            "Channel": "={{ $json.channel }}",
            "Post ID": "={{ $json.postId }}",
            "Replies": "={{ $json.replies }}",
            "Post key": "={{ $json.itemKey }}",
            "Post date": "={{ $json.postDate }}",
            "Reactions": "={{ $json.reactions }}",
            "Media count": "={{ $json.mediaCount }}",
            "Subscribers": "={{ $json.subscribers }}",
            "Collected at": "={{ $json.collectedAt }}",
            "Channel title": "={{ $json.channelTitle }}",
            "Comments scraped": "={{ $json.commentsScraped }}"
          },
          "schema": [
            {
              "id": "Post key",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Post key",
              "defaultMatch": true,
              "canBeUsedToMatch": true
            },
            {
              "id": "Collected at",
              "type": "number",
              "display": true,
              "required": false,
              "displayName": "Collected at",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Channel",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Channel",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Channel title",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Channel title",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Subscribers",
              "type": "number",
              "display": true,
              "required": false,
              "displayName": "Subscribers",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Post date",
              "type": "number",
              "display": true,
              "required": false,
              "displayName": "Post date",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Post",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Post",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Post ID",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Post ID",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Text",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Text",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Views",
              "type": "number",
              "display": true,
              "required": false,
              "displayName": "Views",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Reactions",
              "type": "number",
              "display": true,
              "required": false,
              "displayName": "Reactions",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Replies",
              "type": "number",
              "display": true,
              "required": false,
              "displayName": "Replies",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Comments scraped",
              "type": "number",
              "display": true,
              "required": false,
              "displayName": "Comments scraped",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Media count",
              "type": "number",
              "display": true,
              "required": false,
              "displayName": "Media count",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Links",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Links",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "Post key"
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {
          "useAppend": true
        },
        "operation": "appendOrUpdate",
        "sheetName": {
          "__rl": true,
          "mode": "id",
          "value": "7001001",
          "cachedResultName": "Telegram Posts"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "1gZzH6q3pJ5fLwz37Cv0AjwTsyA-pdz2YtHa36QC6MIo",
          "cachedResultName": "FetchCat n8n QA"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "70000000-0000-0000-4000-8000-000000000020",
      "name": "Workflow Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -2080,
        -416
      ],
      "parameters": {
        "width": 480,
        "height": 920,
        "content": "## Export Telegram Channel Posts to Google Sheets\n\nCollect public Telegram channel posts with fetch_cat/telegram-channel-posts-scraper and keep one clean, sortable row per post in Google Sheets. This workflow works on n8n Cloud or self-hosted n8n, uses only built-in nodes, and does not require OpenAI.\n\n### How it works\n\n1. Starts manually or every day at 09:00.\n2. Reads one or more public channel handles and configurable post limits.\n3. Runs the Apify Actor once for the complete channel batch.\n4. Converts dates, links, engagement, media counts, and post text into human-readable columns.\n5. Updates Google Sheets by a stable channel-and-post key, so reruns refresh existing rows without creating duplicates.\n\n### Setup\n\n- [ ] Enter public channel handles without t.me URLs.\n- [ ] Choose maximum posts per channel and per workflow run.\n- [ ] Create an Apify HTTP Header Auth credential and select it in both HTTP Request nodes.\n- [ ] Copy the provided spreadsheet template, connect Google Sheets, and select its Telegram Posts tab.\n- [ ] Run manually, inspect the rows, and publish only when you are ready to enable the daily schedule.\n\nOnly public posts are collected. The workflow does not join channels, send messages, or access private Telegram data."
      },
      "typeVersion": 1
    },
    {
      "id": "70000000-0000-0000-4000-8000-000000000021",
      "name": "Start the Export",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1520,
        -144
      ],
      "parameters": {
        "color": 7,
        "width": 320,
        "height": 480,
        "content": "## Start the export\n\nRun manually during setup or publish the workflow to collect posts every day at 09:00."
      },
      "typeVersion": 1
    },
    {
      "id": "70000000-0000-0000-4000-8000-000000000022",
      "name": "Configure and Collect",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1120,
        -144
      ],
      "parameters": {
        "color": 7,
        "width": 720,
        "height": 480,
        "content": "## Configure and collect\n\nEnter public channel handles and post limits, validate them, then run the Apify Actor once for the complete batch."
      },
      "typeVersion": 1
    },
    {
      "id": "70000000-0000-0000-4000-8000-000000000023",
      "name": "Prepare and Save Posts",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -352,
        -144
      ],
      "parameters": {
        "color": 7,
        "width": 592,
        "height": 480,
        "content": "## Prepare and save posts\n\nCreate readable dates, direct links, metrics, and stable keys, then upsert one row per Telegram post in Google Sheets."
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "timezone": "Europe/Lisbon",
    "binaryMode": "separate",
    "callerPolicy": "workflowsFromSameOwner",
    "executionOrder": "v1",
    "saveManualExecutions": true
  },
  "versionId": "5620803a-aae8-40c6-ab2c-75d938309733",
  "nodeGroups": [],
  "connections": {
    "Manual Trigger": {
      "main": [
        [
          {
            "node": "1. Enter Telegram Channels",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Daily 09:00 Trigger": {
      "main": [
        [
          {
            "node": "1. Enter Telegram Channels",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "1. Enter Telegram Channels": {
      "main": [
        [
          {
            "node": "Validate Channels and Build Actor Input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Download Telegram Post Results": {
      "main": [
        [
          {
            "node": "Prepare Human-Readable Post Rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Human-Readable Post Rows": {
      "main": [
        [
          {
            "node": "3. Save Posts to Google Sheets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "2. Run Telegram Channel Posts Scraper": {
      "main": [
        [
          {
            "node": "Download Telegram Post Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate Channels and Build Actor Input": {
      "main": [
        [
          {
            "node": "2. Run Telegram Channel Posts Scraper",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}