{
  "name": "Twitter Stream Monitor",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "twitter-stream-monitor",
        "options": {
          "responseCode": {
            "values": {}
          }
        }
      },
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [
        -944,
        144
      ],
      "id": "80671ad3-078a-4bf8-be69-05ce8a61f8b9",
      "name": "Twitter Stream Webhook"
    },
    {
      "parameters": {
        "jsCode": "// Get input data - headers are inside json!\nconst data = $input.item.json;\nconst body = data.body || data;\nconst headers = data.headers || {};\n\n// Your TwitterAPI.io API key\nconst EXPECTED_API_KEY: \"YOUR_API_KEY\";\n\nconsole.log('\ud83d\udccb Checking API key...');\n\n// Get API key from headers\nconst receivedApiKey = headers['x-api-key'];\n\nif (!receivedApiKey) {\n  console.log('\u274c No API key found in headers');\n  throw new Error('No API key found in request');\n}\n\nif (receivedApiKey !== EXPECTED_API_KEY) {\n  console.log(`\u274c Invalid API key: got \"${receivedApiKey}\"`);\n  throw new Error('Invalid API key');\n}\n\nconsole.log('\u2705 Valid webhook request from TwitterAPI.io');\nconsole.log(`\ud83d\udce5 Event: ${body.eventtype || body.event_type}`);\nconsole.log(`\ud83d\udce5 Rule: ${body.ruletag || body.rule_tag}`);\nconsole.log(`\ud83d\udce5 Tweets: ${body.tweets?.length || 0}`);\n\n// Pass through the BODY only (not the headers wrapper)\nreturn [{\n  json: body\n}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -736,
        144
      ],
      "id": "aa8bdf8a-d18e-4b63-b97d-efab427ae2f2",
      "name": "Validate API Key"
    },
    {
      "parameters": {
        "jsCode": "const body = $input.item.json;\n\n// Check event type\nif (body.eventtype !== 'tweet' && body.event_type !== 'tweet') {\n  console.log('\u26a0\ufe0f Non-tweet event, skipping');\n  return [];\n}\n\nconst tweets = body.tweets || [];\nconst ruleTag = body.ruletag || body.rule_tag || 'unknown';\n\nconsole.log(`\ud83d\udce5 Processing ${tweets.length} tweets from rule \"${ruleTag}\"`);\n\nif (tweets.length === 0) {\n  console.log('No tweets in webhook payload');\n  return [];\n}\n\n// Normalize each tweet to match your existing workflow format\nreturn tweets.map(tweet => ({\n  json: {\n    // Keep all original TwitterAPI.io fields\n    ...tweet,\n    \n    // Normalize date field (ensure consistency)\n    createdAt: tweet.created_at || tweet.createdAt,\n    \n    // Normalize reply fields\n    isReply: tweet.is_reply || tweet.isReply || !!tweet.inReplyToId || !!tweet.in_reply_to_status_id,\n    inReplyToId: tweet.inReplyToId || tweet.in_reply_to_status_id || null,\n    inReplyToUsername: tweet.inReplyToUsername || tweet.in_reply_to_screen_name || null,\n    \n    // Add webhook metadata\n    webhook_source: ruleTag,\n    webhook_timestamp: body.timestamp\n  }\n}));"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -528,
        144
      ],
      "id": "7aa1de1b-4031-405b-b38c-058da2194a30",
      "name": "Normalize Webhook Tweets"
    },
    {
      "parameters": {
        "jsCode": "const tweets = $input.all();\nconst apiKey: \"YOUR_API_KEY\";\n\nconsole.log(`Processing ${tweets.length} tweets for reply enrichment`);\n\n// Collect all reply IDs first\nconst replyIds = [];\nconst tweetMap = {};\n\ntweets.forEach(item => {\n  const tweet = item.json;\n  tweetMap[tweet.id] = tweet;\n  \n  if (tweet.isReply && tweet.inReplyToId) {\n    replyIds.push(tweet.inReplyToId);\n  }\n});\n\nconsole.log(`Found ${replyIds.length} replies to fetch`);\n\n// BATCH FETCH: Get up to 100 tweets per API call\nconst BATCH_SIZE = 100;\nconst originalTweets = {};\n\nfor (let i = 0; i < replyIds.length; i += BATCH_SIZE) {\n  const batch = replyIds.slice(i, i + BATCH_SIZE);\n  const batchIds = batch.join(',');\n  \n  console.log(`Fetching batch ${Math.floor(i/BATCH_SIZE) + 1}: ${batch.length} tweets`);\n  \n  try {\n    const response = await this.helpers.httpRequest({\n      method: 'GET',\n      url: `https://api.twitterapi.io/twitter/tweets?tweet_ids=${batchIds}`,\n      headers: {\n        'X-API-Key': apiKey,\n        'Accept': 'application/json'\n      },\n      json: true\n    });\n    \n    // Store fetched tweets by ID\n    if (response.tweets) {\n      response.tweets.forEach(t => {\n        originalTweets[t.id] = t;\n      });\n    }\n    \n    console.log(`\u2705 Fetched ${response.tweets?.length || 0} original tweets`);\n    \n    // Rate limit: wait 1 second between batches\n    if (i + BATCH_SIZE < replyIds.length) {\n      await new Promise(resolve => setTimeout(resolve, 1000));\n    }\n    \n  } catch (error) {\n    console.log(`\u26a0\ufe0f Batch fetch failed: ${error.message}`);\n  }\n}\n\n// Now enrich all tweets\nconst enrichedTweets = tweets.map(item => {\n  const tweet = item.json;\n  \n  if (tweet.isReply && tweet.inReplyToId) {\n    const originalTweet = originalTweets[tweet.inReplyToId];\n    \n    if (originalTweet) {\n      return {\n        json: {\n          ...tweet,\n          original_tweet_id: originalTweet.id,\n          original_author_username: originalTweet.author?.userName || '',\n          original_tweet_url: originalTweet.url || originalTweet.twitterUrl,\n          original_full_text: originalTweet.text || ''\n        }\n      };\n    } else {\n      return {\n        json: {\n          ...tweet,\n          original_tweet_id: tweet.inReplyToId,\n          original_author_username: tweet.inReplyToUsername,\n          original_tweet_url: `https://twitter.com/${tweet.inReplyToUsername}/status/${tweet.inReplyToId}`,\n          original_full_text: '(Original tweet not found)'\n        }\n      };\n    }\n  }\n  \n  // Not a reply\n  return {\n    json: {\n      ...tweet,\n      original_tweet_id: '',\n      original_author_username: '',\n      original_tweet_url: '',\n      original_full_text: ''\n    }\n  };\n});\n\nconsole.log(`\u2705 Enriched ${enrichedTweets.length} tweets`);\nreturn enrichedTweets;"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -320,
        144
      ],
      "id": "18365328-601f-410e-aaa2-5f9c797e7435",
      "name": "Fetch Original Tweets for Replies"
    },
    {
      "parameters": {
        "jsCode": "const tweets = $input.all();\n\nconsole.log(`Formatting ${tweets.length} tweets for Google Sheets`);\n\nreturn tweets.map(item => {\n  const tweet = item.json;\n  \n  // Extract media URLs\n  const mediaUrls = [];\n  if (tweet.extendedEntities?.media) {\n    tweet.extendedEntities.media.forEach(m => {\n      if (m.mediaurlhttps || m.media_url_https) {\n        mediaUrls.push(m.mediaurlhttps || m.media_url_https);\n      }\n    });\n  }\n  \n  // Safe extraction\n  const authorUsername = tweet.author?.userName || tweet.userName || 'unknown';\n  \n  // Construct Drive folder URL (if media exists)\n  const driveFolderUrl = mediaUrls.length > 0 \n    ? 'https://drive.google.com/drive/folders/YOUR_FOLDER_ID' \n    : '';\n  \n  return {\n    json: {\n      tweet_id: tweet.id,\n      created_at: tweet.createdAt,\n      author_username: authorUsername,\n      tweet_url: tweet.url || tweet.twitterUrl,\n      full_text: tweet.text,\n      is_reply: tweet.isReply ? 'TRUE' : 'FALSE',\n      reply_to_tweet_id: tweet.inReplyToId || '',\n      reply_to_username: tweet.inReplyToUsername || '',\n      original_tweet_id: tweet.original_tweet_id || '',\n      original_author_username: tweet.original_author_username || '',\n      original_tweet_url: tweet.original_tweet_url || '',\n      original_full_text: tweet.original_full_text || '',\n      // NEW: Quote tweet fields\n      is_quote: tweet.quoted_tweet_id ? 'TRUE' : 'FALSE',\n      quoted_tweet_id: tweet.quoted_tweet_id || '',\n      quoted_author_username: tweet.quoted_author_username || '',\n      quoted_tweet_url: tweet.quoted_tweet_url || '',\n      quoted_full_text: tweet.quoted_full_text || '',\n      media_urls: mediaUrls.join(', '),\n      drive_folder_url: driveFolderUrl\n    }\n  };\n});"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        144,
        144
      ],
      "id": "bda3fe19-7e0c-46c1-ac69-74b5dc3cac34",
      "name": "Format Data for Sheets"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 3
          },
          "conditions": [
            {
              "id": "06a27676-e542-4a6b-adaf-ce1fb07a9796",
              "leftValue": "={{ $json.media_urls }}",
              "rightValue": "",
              "operator": {
                "type": "string",
                "operation": "notEmpty",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        352,
        144
      ],
      "id": "6d9de146-9ab1-4bc9-84b7-b2028f8cb6be",
      "name": "Check if Media Exists"
    },
    {
      "parameters": {
        "jsCode": "const tweets = $input.all();\n\nconsole.log(`Processing ${tweets.length} tweets with media`);\n\n// Process each tweet\nconst allMediaItems = [];\n\ntweets.forEach(item => {\n  const tweetData = item.json;\n  const mediaUrls = tweetData.media_urls;\n\n  // If no media, skip\n  if (!mediaUrls || mediaUrls.trim() === '') {\n    console.log(`\u26a0\ufe0f No media URLs for tweet ${tweetData.tweet_id}`);\n    return;\n  }\n\n  // Split comma-separated URLs into array\n  const urlArray = mediaUrls.split(',').map(url => url.trim()).filter(url => url);\n\n  console.log(`\ud83d\udcf8 Found ${urlArray.length} media files for tweet ${tweetData.tweet_id}`);\n\n  // Create one item per media URL\n  urlArray.forEach((url, index) => {\n    // Determine file extension from URL\n    let extension = '.jpg';\n    if (url.includes('.mp4')) extension = '.mp4';\n    if (url.includes('.png')) extension = '.png';\n    if (url.includes('.gif')) extension = '.gif';\n\n    allMediaItems.push({\n      json: {\n        ...tweetData,\n        media_url: url,\n        media_index: index + 1,\n        filename: `${tweetData.author_username}_${tweetData.tweet_id}_${index + 1}${extension}`\n      }\n    });\n  });\n});\n\nconsole.log(`\u2705 Total media items to download: ${allMediaItems.length}`);\n\nreturn allMediaItems;"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        560,
        48
      ],
      "id": "b0c996d2-7f0a-496c-8d85-4ff126875932",
      "name": "Split Media URLs into Array"
    },
    {
      "parameters": {
        "jsCode": "// Get data from both nodes\nconst uploadedFiles = $input.all(); // From Google Drive Upload\nconst downloadedFiles = $('Download Media File').all(); // From Download Media File node\n\nconsole.log(`Processing ${uploadedFiles.length} uploaded files`);\n\n// Create a map of index -> tweet data from Download Media File\nconst tweetDataMap = {};\ndownloadedFiles.forEach((item, index) => {\n  tweetDataMap[index] = item.json;\n});\n\n// Group by tweet_id\nconst grouped = {};\n\nuploadedFiles.forEach((item, index) => {\n  const driveFileId = item.json.id; // Google Drive file ID\n  const driveUrl = `https://drive.google.com/file/d/${driveFileId}/view`;\n  \n  // Get the original tweet data from Download Media File node\n  const tweetData = tweetDataMap[index];\n  \n  if (!tweetData) {\n    console.log(`\u26a0\ufe0f No tweet data found for index ${index}`);\n    return;\n  }\n  \n  const tweetId = tweetData.tweet_id;\n  \n  if (!grouped[tweetId]) {\n    grouped[tweetId] = {\n      tweet_id: tweetData.tweet_id,\n      created_at: tweetData.created_at,\n      author_username: tweetData.author_username,\n      tweet_url: tweetData.tweet_url,\n      full_text: tweetData.full_text,\n      is_reply: tweetData.is_reply,\n      reply_to_tweet_id: tweetData.reply_to_tweet_id || '',\n      reply_to_username: tweetData.reply_to_username || '',\n      original_tweet_id: tweetData.original_tweet_id || '',\n      original_author_username: tweetData.original_author_username || '',\n      original_tweet_url: tweetData.original_tweet_url || '',\n      original_full_text: tweetData.original_full_text || '',\n      // NEW: Quote tweet fields\n      is_quote: tweetData.is_quote || 'FALSE',\n      quoted_tweet_id: tweetData.quoted_tweet_id || '',\n      quoted_author_username: tweetData.quoted_author_username || '',\n      quoted_tweet_url: tweetData.quoted_tweet_url || '',\n      quoted_full_text: tweetData.quoted_full_text || '',\n      media_urls: tweetData.media_urls,\n      drive_file_paths: []\n    };\n  }\n  \n  grouped[tweetId].drive_file_paths.push(driveUrl);\n});\n\n// Return one item per tweet with all drive paths joined\nconst result = Object.values(grouped).map(tweet => ({\n  json: {\n    ...tweet,\n    drive_file_paths: tweet.drive_file_paths.join(', ')\n  }\n}));\n\nconsole.log(`\u2705 Grouped into ${result.length} tweets with Drive paths`);\nconsole.log(`Sample:`, JSON.stringify(result[0], null, 2));\n\nreturn result;"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1232,
        48
      ],
      "id": "579a3ef5-3b8d-4379-baae-7aaa34082c5a",
      "name": "Collect Drive Paths per Tweet"
    },
    {
      "parameters": {
        "name": "={{ $json.filename }}",
        "driveId": {
          "__rl": true,
          "value": "My Drive",
          "mode": "list",
          "cachedResultName": "My Drive",
          "cachedResultUrl": "https://drive.google.com/drive/my-drive"
        },
        "folderId": {
          "__rl": true,
          "value": "1dMkz2p41j9PclOmZNW85zSXpGx7rJo51",
          "mode": "list",
          "cachedResultName": "Twitter Archive",
          "cachedResultUrl": "https://drive.google.com/drive/folders/1dMkz2p41j9PclOmZNW85zSXpGx7rJo51"
        },
        "options": {
          "simplifyOutput": false
        }
      },
      "type": "n8n-nodes-base.googleDrive",
      "typeVersion": 3,
      "position": [
        1008,
        48
      ],
      "id": "faebc305-6941-4f1f-8438-eeea71ad63ac",
      "name": "Upload to Google Drive",
      "credentials": {
        "googleDriveOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const items = $input.all();\n\nconst results = [];\n\nfor (const item of items) {\n  const url = item.json.media_url;\n  \n  if (!url) {\n    console.log('\u26a0\ufe0f No media_url found, skipping item');\n    continue;\n  }\n  \n  try {\n    console.log(`Downloading: ${url}`);\n    \n    // Download the file\n    const response = await this.helpers.httpRequest({\n      method: 'GET',\n      url: url,\n      returnFullResponse: true,\n      encoding: 'arraybuffer'\n    });\n    \n    // Determine mime type from response or URL\n    let mimeType = response.headers['content-type'] || 'image/jpeg';\n    \n    // response.body is already a Buffer when encoding is 'arraybuffer'\n    const binaryData = Buffer.isBuffer(response.body) \n      ? response.body \n      : Buffer.from(response.body);\n    \n    results.push({\n      json: {\n        // Keep ALL original tweet data (with underscores)\n        ...item.json\n      },\n      binary: {\n        data: {\n          data: binaryData.toString('base64'),\n          mimeType: mimeType,\n          fileName: item.json.filename,\n          fileExtension: item.json.filename.split('.').pop()\n        }\n      }\n    });\n    \n    console.log(`\u2705 Downloaded: ${item.json.filename} (${mimeType})`);\n  } catch (error) {\n    console.log(`\u274c Failed to download ${url}: ${error.message}`);\n    // Still add the item so workflow can continue\n    results.push({\n      json: {\n        ...item.json,\n        download_error: error.message\n      }\n    });\n  }\n}\n\nconsole.log(`Total files processed: ${results.length}`);\n\nreturn results;"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        784,
        48
      ],
      "id": "94de1787-cce7-4a26-ba76-73e976d107c1",
      "name": "Download Media File"
    },
    {
      "parameters": {},
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3.2,
      "position": [
        1472,
        144
      ],
      "id": "69a3da90-c35a-45bb-9330-a88a0f4821ab",
      "name": "Merge Tweets (With & Without Media)"
    },
    {
      "parameters": {
        "operation": "appendOrUpdate",
        "documentId": {
          "__rl": true,
          "value": "1wCO_3eg9FPRHsEbFkuO786Ux-XvP7jew6ZCssO-U74A",
          "mode": "list",
          "cachedResultName": "Twitter Monitor - Data Archive",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_GOOGLE_SHEET_ID/edit"
        },
        "sheetName": {
          "__rl": true,
          "value": "gid=0",
          "mode": "list",
          "cachedResultName": "Tweets",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_GOOGLE_SHEET_ID/edit"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "tweet_id": "={{ $json.tweet_id }}",
            "created_at": "={{ $json.created_at }}",
            "author_username": "={{ $json.author_username }}",
            "tweet_url": "={{ $json.tweet_url }}",
            "full_text": "={{ $json.full_text }}",
            "is_reply": "={{ $json.is_reply }}",
            "reply_to_tweet_id": "={{ $json.reply_to_tweet_id }}",
            "reply_to_username": "={{ $json.reply_to_username }}",
            "original_tweet_id": "={{ $json.original_tweet_id }}",
            "original_author_username": "={{ $json.original_author_username }}",
            "original_tweet_url": "={{ $json.original_tweet_url }}",
            "original_full_text": "={{ $json.original_full_text }}",
            "media_urls": "={{ $json.media_urls }}",
            "drive_file_paths": "={{ $json.drive_file_paths }}",
            "quoted_tweet_id": "={{ $json.quoted_tweet_id }}",
            "quoted_author_username": "={{ $json.quoted_author_username }}",
            "quoted_tweet_url": "={{ $json.quoted_tweet_url }}",
            "quoted_full_text": "={{ $json.quoted_full_text }}"
          },
          "matchingColumns": [
            "tweet_id"
          ],
          "schema": [
            {
              "id": "tweet_id",
              "displayName": "tweet_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": "created_at",
              "displayName": "created_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "author_username",
              "displayName": "author_username",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "tweet_url",
              "displayName": "tweet_url",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "full_text",
              "displayName": "full_text",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "is_reply",
              "displayName": "is_reply",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "reply_to_tweet_id",
              "displayName": "reply_to_tweet_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "reply_to_username",
              "displayName": "reply_to_username",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "original_tweet_id",
              "displayName": "original_tweet_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "original_author_username",
              "displayName": "original_author_username",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "original_tweet_url",
              "displayName": "original_tweet_url",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "original_full_text",
              "displayName": "original_full_text",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "quoted_tweet_id",
              "displayName": "quoted_tweet_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": "quoted_author_username",
              "displayName": "quoted_author_username",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": "quoted_tweet_url",
              "displayName": "quoted_tweet_url",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": "quoted_full_text",
              "displayName": "quoted_full_text",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": "media_urls",
              "displayName": "media_urls",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "drive_file_paths",
              "displayName": "drive_file_paths",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {}
      },
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        1680,
        144
      ],
      "id": "59d44f2e-710b-48b5-ba7a-30de48e0e25f",
      "name": "Write to Google Sheets",
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "content": "## WEBHOOK INTAKE & VALIDATION\nReceives tweets from TwitterAPI.io webhook\n- Validates API key for security\n- Normalizes tweet data format\n",
        "height": 416,
        "width": 608,
        "color": 4
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -1008,
        -64
      ],
      "id": "50406a24-5ae8-4608-87a3-560cd81b95ac",
      "name": "Sticky Note"
    },
    {
      "parameters": {
        "content": "## REPLY ENRICHMENT\nFetches original tweets for replies\n- Batch fetches up to 100 tweets at a time\n- Adds context to reply tweets",
        "height": 416,
        "width": 224,
        "color": 2
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -384,
        -64
      ],
      "id": "bb5d6264-ba22-45bd-b385-8c2a09e1db1e",
      "name": "Sticky Note1"
    },
    {
      "parameters": {
        "content": "## DATA FORMATTING\nFormats tweet data for Google Sheets\n- Extracts media URLs\n- Structures all 14 fields\n",
        "height": 416,
        "width": 192,
        "color": 5
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        96,
        -80
      ],
      "id": "e5207a43-b63a-4212-8732-711108f1accc",
      "name": "Sticky Note2"
    },
    {
      "parameters": {
        "content": "## MERGE & WRITE\nFinal output stage\n- Merges tweets with/without media\n- Writes all data to Google Sheets\n- Updates existing tweets (no duplicates)",
        "height": 384,
        "width": 416,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1456,
        -64
      ],
      "id": "e1d6f610-9f5c-412f-81ae-db2ef94feac8",
      "name": "Sticky Note5"
    },
    {
      "parameters": {
        "content": "## MEDIA PROCESSING PIPELINE\nHandles tweets with media attachments\n- Downloads images/videos from Twitter\n- Uploads to Google Drive folder\n- Collects Drive links per tweet\n",
        "height": 400,
        "width": 1088,
        "color": 6
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        304,
        -112
      ],
      "id": "e8837c6f-42c9-4134-9cd9-f99c40f79195",
      "name": "Sticky Note6"
    },
    {
      "parameters": {
        "jsCode": "const tweets = $input.all();\n\nconsole.log(`Processing ${tweets.length} tweets for quote enrichment`);\n\n// Enrich all tweets - check if quoted_tweet data already exists\nconst enrichedTweets = tweets.map(item => {\n  const tweet = item.json;\n  \n  // Check if this tweet has a quoted tweet\n  if (tweet.quoted_tweet && tweet.quoted_tweet.id) {\n    const quotedTweet = tweet.quoted_tweet;\n    \n    return {\n      json: {\n        ...tweet,\n        quoted_tweet_id: quotedTweet.id,\n        quoted_author_username: quotedTweet.author?.userName || '',\n        quoted_tweet_url: quotedTweet.url || quotedTweet.twitterUrl || `https://x.com/${quotedTweet.author?.userName}/status/${quotedTweet.id}`,\n        quoted_full_text: quotedTweet.text || ''\n      }\n    };\n  }\n  \n  // Not a quote tweet\n  return {\n    json: {\n      ...tweet,\n      quoted_tweet_id: '',\n      quoted_author_username: '',\n      quoted_tweet_url: '',\n      quoted_full_text: ''\n    }\n  };\n});\n\nconsole.log(`\u2705 Enriched ${enrichedTweets.length} tweets with quote data`);\nreturn enrichedTweets;"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -112,
        144
      ],
      "id": "b7c89670-5bff-4d6c-988b-3d8bda88500a",
      "name": "Fetch Quoted Tweets"
    },
    {
      "parameters": {
        "content": "## QUOTE TWEET ENRICHMENT\n- Adds: quoted tweet ID, author, URL, full text",
        "height": 416,
        "width": 192
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -144,
        -64
      ],
      "id": "bf416917-7da5-4c94-917d-2c6c0d90544b",
      "name": "Sticky Note3"
    }
  ],
  "connections": {
    "Twitter Stream Webhook": {
      "main": [
        [
          {
            "node": "Validate API Key",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate API Key": {
      "main": [
        [
          {
            "node": "Normalize Webhook Tweets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Webhook Tweets": {
      "main": [
        [
          {
            "node": "Fetch Original Tweets for Replies",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Original Tweets for Replies": {
      "main": [
        [
          {
            "node": "Fetch Quoted Tweets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Data for Sheets": {
      "main": [
        [
          {
            "node": "Check if Media Exists",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check if Media Exists": {
      "main": [
        [
          {
            "node": "Split Media URLs into Array",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Merge Tweets (With & Without Media)",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Split Media URLs into Array": {
      "main": [
        [
          {
            "node": "Download Media File",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload to Google Drive": {
      "main": [
        [
          {
            "node": "Collect Drive Paths per Tweet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Download Media File": {
      "main": [
        [
          {
            "node": "Upload to Google Drive",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Collect Drive Paths per Tweet": {
      "main": [
        [
          {
            "node": "Merge Tweets (With & Without Media)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Tweets (With & Without Media)": {
      "main": [
        [
          {
            "node": "Write to Google Sheets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Quoted Tweets": {
      "main": [
        [
          {
            "node": "Format Data for Sheets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "availableInMCP": false
  },
  "versionId": "a3745d91-7128-4d49-a037-8e67a3317bb6",
  "id": "M7dtj8yS3-IyGv4L0QWnH",
  "tags": []
}