{
  "id": "5ZNr3c1pfy0x6T41",
  "name": "Export TikTok Profiles to Google Sheets with Apify",
  "tags": [],
  "nodes": [
    {
      "id": "60000000-0000-0000-4000-8000-000000000001",
      "name": "Manual Trigger",
      "type": "n8n-nodes-base.manualTrigger",
      "position": [
        -1568,
        256
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "60000000-0000-0000-4000-8000-000000000008",
      "name": "Daily 09:00 Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -1568,
        64
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtHour": 9
            }
          ]
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "60000000-0000-0000-4000-8000-000000000002",
      "name": "1. Enter TikTok Profiles",
      "type": "n8n-nodes-base.set",
      "position": [
        -1296,
        192
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "tiktok-profiles",
              "name": "profiles",
              "type": "string",
              "value": "selenagomez\ntherock\nbillieeilish"
            },
            {
              "id": "tiktok-limit",
              "name": "Maximum Profiles Per Run",
              "type": "number",
              "value": 25
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "60000000-0000-0000-4000-8000-000000000003",
      "name": "Validate Profiles and Build Actor Input",
      "type": "n8n-nodes-base.code",
      "position": [
        -1056,
        192
      ],
      "parameters": {
        "jsCode": "const input = $input.first()?.json || {};\nconst raw = String(input.profiles || '').split(/[\\n,]+/).map((value) => value.trim()).filter(Boolean);\nconst maxProfiles = Math.max(1, Math.min(500, Math.floor(Number(input['Maximum Profiles Per Run']) || 25)));\nif (raw.length === 0) throw new Error('Enter at least one TikTok handle or profile URL.');\nconst handles = [];\nconst profileUrls = [];\nconst seen = new Set();\nfor (const value of raw) {\n  const urlMatch = value.match(/(?:https?:\\/\\/)?(?:www\\.)?tiktok\\.com\\/@([A-Za-z0-9._-]+)/i);\n  const handle = (urlMatch?.[1] || value.replace(/^@/, '')).trim();\n  if (!/^[A-Za-z0-9._-]{2,64}$/.test(handle)) throw new Error('Invalid TikTok profile: ' + value);\n  const key = handle.toLowerCase();\n  if (seen.has(key)) continue;\n  seen.add(key);\n  if (urlMatch) profileUrls.push({ url: 'https://www.tiktok.com/@' + handle });\n  else handles.push(handle);\n}\nif (seen.size > maxProfiles) throw new Error('You entered ' + seen.size + ' unique profiles but Maximum Profiles Per Run is ' + maxProfiles + '.');\nreturn [{ json: { maxProfiles, actorInput: { handles, profileUrls, maxProfiles, includeRecentVideos: false, maxVideosPerProfile: 0, maxConcurrency: 3, runTimeSecs: 180, proxyConfiguration: { useApifyProxy: true } } } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "60000000-0000-0000-4000-8000-000000000004",
      "name": "2. Run FetchCat TikTok Profile Scraper",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -816,
        192
      ],
      "parameters": {
        "url": "https://api.apify.com/v2/acts/B5qu4d1iteAp2TaF1/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": "60000000-0000-0000-4000-8000-000000000005",
      "name": "Download TikTok Profile Results",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -576,
        192
      ],
      "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 Profiles and Build Actor Input\").first().json.maxProfiles }}"
            }
          ]
        }
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.3
    },
    {
      "id": "60000000-0000-0000-4000-8000-000000000006",
      "name": "Prepare Human-Readable Profile Rows",
      "type": "n8n-nodes-base.code",
      "position": [
        -336,
        192
      ],
      "parameters": {
        "jsCode": "\nconst rows = $input.all().map((item) => item.json).filter((item) => item && item.handle && !item.error);\nif (rows.length === 0) throw new Error('TikTok returned no public profiles. Check the handles or profile URLs and inspect the Actor run.');\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 stateLabel = (value, label) => value === true ? '\u2611 ' + label : '\u2715 No';\nreturn rows.map((profile) => {\n  const handleKey = String(profile.handle).trim().replace(/^@/, '').toLowerCase();\n  const profileUrl = String(profile.profileUrl || ('https://www.tiktok.com/@' + handleKey));\n  return { json: {\n    updatedAt: serial(profile.scrapedAt),\n    scrapedAtIso: new Date(profile.scrapedAt || Date.now()).toISOString(),\n    profileLink: formula(profileUrl, '@' + handleKey),\n    profileUrl,\n    handle: '@' + handleKey,\n    handleKey,\n    nickname: String(profile.nickname || ''),\n    bio: String(profile.signature || ''),\n    followers: numberOrNull(profile.followers),\n    following: numberOrNull(profile.following),\n    likes: numberOrNull(profile.likes),\n    videosCount: numberOrNull(profile.videosCount),\n    likesPerVideo: numberOrNull(profile.likesPerVideo),\n    followerFollowingRatio: numberOrNull(profile.followerFollowingRatio),\n    verified: stateLabel(profile.verified, 'Verified'),\n    privateAccount: stateLabel(profile.privateAccount, 'Private account'),\n    region: String(profile.region || ''),\n    language: String(profile.language || ''),\n    bioLink: formula(profile.bioLink, 'Open bio link'),\n    bioLinkUrl: String(profile.bioLink || ''),\n    commerceUser: stateLabel(profile.commerceUser, 'Commerce account'),\n    tiktokSeller: stateLabel(profile.tiktokSeller, 'TikTok seller'),\n    isLive: stateLabel(profile.isLive, 'Live now'),\n    userId: String(profile.userId || '')\n  } };\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "60000000-0000-0000-4000-8000-000000000007",
      "name": "3. Save Profiles to Google Sheets",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        -96,
        192
      ],
      "parameters": {
        "columns": {
          "value": {
            "Bio": "={{ $json.bio }}",
            "Name": "={{ $json.nickname }}",
            "Region": "={{ $json.region }}",
            "Videos": "={{ $json.videosCount }}",
            "Profile": "={{ $json.profileLink }}",
            "User ID": "={{ $json.userId }}",
            "Bio link": "={{ $json.bioLink }}",
            "Language": "={{ $json.language }}",
            "Live now": "={{ $json.isLive }}",
            "Verified": "={{ $json.verified }}",
            "Followers": "={{ $json.followers }}",
            "Following": "={{ $json.following }}",
            "Updated at": "={{ $json.updatedAt }}",
            "Total likes": "={{ $json.likes }}",
            "TikTok handle": "={{ $json.handle }}",
            "TikTok seller": "={{ $json.tiktokSeller }}",
            "Likes per video": "={{ $json.likesPerVideo }}",
            "Private account": "={{ $json.privateAccount }}",
            "Commerce account": "={{ $json.commerceUser }}",
            "Follower/following ratio": "={{ $json.followerFollowingRatio }}"
          },
          "schema": [
            {
              "id": "Updated at",
              "type": "number",
              "display": true,
              "required": false,
              "displayName": "Updated at",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Profile",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Profile",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "TikTok handle",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "TikTok handle",
              "defaultMatch": true,
              "canBeUsedToMatch": true
            },
            {
              "id": "Name",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Name",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Bio",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Bio",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Followers",
              "type": "number",
              "display": true,
              "required": false,
              "displayName": "Followers",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Following",
              "type": "number",
              "display": true,
              "required": false,
              "displayName": "Following",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Total likes",
              "type": "number",
              "display": true,
              "required": false,
              "displayName": "Total likes",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Videos",
              "type": "number",
              "display": true,
              "required": false,
              "displayName": "Videos",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Likes per video",
              "type": "number",
              "display": true,
              "required": false,
              "displayName": "Likes per video",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Follower/following ratio",
              "type": "number",
              "display": true,
              "required": false,
              "displayName": "Follower/following ratio",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Verified",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Verified",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Private account",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Private account",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Region",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Region",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Language",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Language",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Bio link",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Bio link",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Commerce account",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Commerce account",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "TikTok seller",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "TikTok seller",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Live now",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Live now",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "User ID",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "User ID",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "TikTok handle"
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {
          "useAppend": true
        },
        "operation": "appendOrUpdate",
        "sheetName": {
          "__rl": true,
          "mode": "id",
          "value": "186414753",
          "cachedResultName": "TikTok Profiles"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "1a53w6kA5EM0iPSIZ-EC1-lsPRlkrx2vk6ZK7mgLIeaI",
          "cachedResultName": "TikTok Profiles to Google Sheets - n8n Template"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "60000000-0000-0000-4000-8000-000000000020",
      "name": "Workflow Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -2208,
        -304
      ],
      "parameters": {
        "width": 480,
        "height": 900,
        "content": "## Export TikTok Profiles to Google Sheets\n\nCollect current public TikTok profile data with `fetch_cat/tiktok-profile-scraper` and save one clean, sortable row per creator. This workflow works on n8n Cloud or self-hosted n8n, uses built-in nodes, and does not require OpenAI.\n\n### How it works\n\n1. Starts manually or every day at 09:00 and reads handles or full TikTok profile URLs from one editable Set node.\n2. Validates and deduplicates the list before spending Actor credits.\n3. Runs the FetchCat TikTok Profile Scraper once for the complete batch.\n4. Converts public metrics and account signals into human-readable columns.\n5. Updates Google Sheets by TikTok handle, so reruns refresh existing rows instead of creating duplicates.\n\n### Setup\n\n- [ ] [Open the public spreadsheet template](https://docs.google.com/spreadsheets/d/11sXzrv9g7rECryFt6HkWcXQxh7hpyHteuRT-cxcQZrE/edit?usp=drive_link), choose File > Make a copy, and save the copy in your Google Drive.\n- [ ] Paste up to 500 handles or profile URLs into 1. Enter TikTok Profiles.\n- [ ] Create an Apify HTTP Header Auth credential and select it in both FetchCat request nodes.\n- [ ] Connect Google Sheets and select your copied spreadsheet and its TikTok Profiles tab in 3. Save Profiles to Google Sheets.\n- [ ] Run manually and inspect the resulting profile table, then publish the workflow to enable the daily schedule.\n\nRecent videos are intentionally disabled because TikTok does not expose them consistently."
      },
      "typeVersion": 1
    },
    {
      "id": "60000000-0000-0000-4000-8000-000000000021",
      "name": "Trigger Workflow",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1616,
        -176
      ],
      "parameters": {
        "color": 7,
        "height": 608,
        "content": "## Trigger workflow\n\nRun manually while setting up and testing, or publish the workflow to run it every day at 09:00."
      },
      "typeVersion": 1
    },
    {
      "id": "60000000-0000-0000-4000-8000-000000000022",
      "name": "Profile Input and Validation",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1344,
        -48
      ],
      "parameters": {
        "color": 7,
        "width": 432,
        "height": 416,
        "content": "## Profile input and validation\n\nEnter TikTok handles or full profile URLs, choose a batch limit, and validate and deduplicate the list before using Actor credits."
      },
      "typeVersion": 1
    },
    {
      "id": "60000000-0000-0000-4000-8000-000000000023",
      "name": "Scrape and Download Data",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -864,
        -48
      ],
      "parameters": {
        "color": 7,
        "width": 432,
        "height": 416,
        "content": "## Scrape and download data\n\nRun `fetch_cat/tiktok-profile-scraper` once for the complete batch, wait for completion, and download the resulting public profile dataset."
      },
      "typeVersion": 1
    },
    {
      "id": "60000000-0000-0000-4000-8000-000000000024",
      "name": "Process and Save to Sheets",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -384,
        -48
      ],
      "parameters": {
        "color": 7,
        "width": 432,
        "height": 416,
        "content": "## Process and save to Sheets\n\nCreate human-readable dates, metrics, links, and account labels, then update Google Sheets using TikTok handle as the unique key."
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "timezone": "Europe/Lisbon",
    "binaryMode": "separate",
    "callerPolicy": "workflowsFromSameOwner",
    "executionOrder": "v1",
    "saveManualExecutions": true
  },
  "versionId": "ec02723b-cdee-45a3-8d83-53f9cc6a619c",
  "nodeGroups": [],
  "connections": {
    "Manual Trigger": {
      "main": [
        [
          {
            "node": "1. Enter TikTok Profiles",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Daily 09:00 Trigger": {
      "main": [
        [
          {
            "node": "1. Enter TikTok Profiles",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "1. Enter TikTok Profiles": {
      "main": [
        [
          {
            "node": "Validate Profiles and Build Actor Input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Download TikTok Profile Results": {
      "main": [
        [
          {
            "node": "Prepare Human-Readable Profile Rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Human-Readable Profile Rows": {
      "main": [
        [
          {
            "node": "3. Save Profiles to Google Sheets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "2. Run FetchCat TikTok Profile Scraper": {
      "main": [
        [
          {
            "node": "Download TikTok Profile Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate Profiles and Build Actor Input": {
      "main": [
        [
          {
            "node": "2. Run FetchCat TikTok Profile Scraper",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}