{
  "id": "2vmW7VCMoHxQAeDi",
  "name": "Auto-post curated remote jobs to Telegram with BrowserAct & Gemini",
  "tags": [],
  "nodes": [
    {
      "id": "72f28ca9-c882-479d-a4d2-53b21ec9b89b",
      "name": "Structured Output Parser",
      "type": "@n8n/n8n-nodes-langchain.outputParserStructured",
      "position": [
        2128,
        720
      ],
      "parameters": {
        "autoFix": true,
        "jsonSchemaExample": "[ { \"text\": \"<b>Senior Developer</b>\\n\\n\ud83c\udfe2 <b>Company:</b> TechCorp\\n\ud83d\udcb0 <b>Salary:</b> $150k\\n\\n<b>About:</b> Leading the backend team.\\n\\n\ud83d\udee0 <b>Stack:</b> Java, AWS\\n\\n<a href=http://link.com>\ud83d\udd17 Apply Here</a>\", \"parse_mode\": \"HTML\", \"disable_web_page_preview\": true } ]"
      },
      "typeVersion": 1.3
    },
    {
      "id": "1cc72c30-636a-4364-835e-046612127be4",
      "name": "Google Gemini",
      "type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini",
      "position": [
        2000,
        736
      ],
      "parameters": {
        "options": {},
        "modelName": "models/gemini-2.5-pro"
      },
      "credentials": {
        "googlePalmApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "2919d33c-7fef-4311-b8d3-3e746183807a",
      "name": "Loop Over Items",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        2752,
        512
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 3
    },
    {
      "id": "5ff7a89e-0eb9-4497-9aa8-6bb1fda7919b",
      "name": "Fix Output",
      "type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini",
      "position": [
        2128,
        848
      ],
      "parameters": {
        "options": {},
        "modelName": "models/gemini-2.5-pro"
      },
      "credentials": {
        "googlePalmApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "10f08ee5-52b6-4dba-900d-310f2abae1f5",
      "name": "Scrape Jobs Data (SimplyHired)",
      "type": "n8n-nodes-browseract.browserAct",
      "position": [
        1056,
        432
      ],
      "parameters": {
        "type": "WORKFLOW",
        "timeout": 7200,
        "workflowId": "68979670614019565",
        "workflowConfig": {
          "value": {},
          "schema": [
            {
              "id": "input-Simplyhired",
              "type": "string",
              "display": true,
              "removed": true,
              "required": false,
              "description": "If left blank, the default value defined in BrowserAct will be used.",
              "displayName": "Simplyhired",
              "defaultMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "input-Simplyhired"
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "open_incognito_mode": false
      },
      "credentials": {
        "browserActApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "002c749a-d8e6-4a42-a309-396fc8b5a680",
      "name": "Scrape Jobs Data (Remotive)",
      "type": "n8n-nodes-browseract.browserAct",
      "position": [
        1056,
        592
      ],
      "parameters": {
        "type": "WORKFLOW",
        "timeout": 7200,
        "workflowId": "68986628591810026",
        "workflowConfig": {
          "value": {},
          "schema": [
            {
              "id": "input-Remotive",
              "type": "string",
              "display": true,
              "removed": true,
              "required": false,
              "description": "If left blank, the default value defined in BrowserAct will be used.",
              "displayName": "Remotive",
              "defaultMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "input-Remotive"
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "open_incognito_mode": false
      },
      "credentials": {
        "browserActApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "280ecc28-bcf7-41ea-a404-c59a269d9dfb",
      "name": "Splitting Remotive Data",
      "type": "n8n-nodes-base.code",
      "onError": "continueRegularOutput",
      "position": [
        1280,
        592
      ],
      "parameters": {
        "jsCode": "// Get the JSON string using the exact path provided by the user.\nconst jsonString = $input.first().json.output.string;\n\nlet parsedData;\n\n// Check if the string exists before attempting to parse\nif (!jsonString) {\n    // Return an empty array or throw an error if no string is found\n    // Throwing an error is usually better to stop the workflow if data is missing.\n    throw new Error(\"Input string is empty or missing at the specified path: $input.first().json.output.string\");\n}\n\ntry {\n    // 1. Parse the JSON string into a JavaScript array of objects\n    parsedData = JSON.parse(jsonString);\n} catch (error) {\n    // Handle JSON parsing errors (e.g., if the string is malformed)\n    throw new Error(`Failed to parse JSON string: ${error.message}`);\n}\n\n// 2. Ensure the parsed data is an array\nif (!Array.isArray(parsedData)) {\n    throw new Error('Parsed data is not an array. It cannot be split into multiple items.');\n}\n\n// 3. Map the array of objects into the n8n item format { json: object }\n// Each element in this array will be treated as a new item by n8n, achieving the split.\nconst outputItems = parsedData.map(item => ({\n    json: item,\n}));\n\n// 4. Return the new array of items\nreturn outputItems;"
      },
      "executeOnce": false,
      "typeVersion": 2,
      "alwaysOutputData": true
    },
    {
      "id": "ad7c8b11-d548-45fd-803f-eb8dee119d6e",
      "name": "Splitting SimplyHired Data",
      "type": "n8n-nodes-base.code",
      "onError": "continueRegularOutput",
      "position": [
        1280,
        432
      ],
      "parameters": {
        "jsCode": "// Get the JSON string using the exact path provided by the user.\nconst jsonString = $input.first().json.output.string;\n\nlet parsedData;\n\n// Check if the string exists before attempting to parse\nif (!jsonString) {\n    // Return an empty array or throw an error if no string is found\n    // Throwing an error is usually better to stop the workflow if data is missing.\n    throw new Error(\"Input string is empty or missing at the specified path: $input.first().json.output.string\");\n}\n\ntry {\n    // 1. Parse the JSON string into a JavaScript array of objects\n    parsedData = JSON.parse(jsonString);\n} catch (error) {\n    // Handle JSON parsing errors (e.g., if the string is malformed)\n    throw new Error(`Failed to parse JSON string: ${error.message}`);\n}\n\n// 2. Ensure the parsed data is an array\nif (!Array.isArray(parsedData)) {\n    throw new Error('Parsed data is not an array. It cannot be split into multiple items.');\n}\n\n// 3. Map the array of objects into the n8n item format { json: object }\n// Each element in this array will be treated as a new item by n8n, achieving the split.\nconst outputItems = parsedData.map(item => ({\n    json: item,\n}));\n\n// 4. Return the new array of items\nreturn outputItems;"
      },
      "typeVersion": 2,
      "alwaysOutputData": true
    },
    {
      "id": "41a99712-6ddf-4a9a-a650-7d60900e74f6",
      "name": "Wait for Both Path Outputs",
      "type": "n8n-nodes-base.merge",
      "position": [
        1488,
        512
      ],
      "parameters": {},
      "typeVersion": 3.2
    },
    {
      "id": "175503ea-1a18-44ed-aa1d-c2d8fa347850",
      "name": "Merge Branch Outputs",
      "type": "n8n-nodes-base.aggregate",
      "position": [
        1840,
        512
      ],
      "parameters": {
        "options": {},
        "aggregate": "aggregateAllItemData"
      },
      "typeVersion": 1
    },
    {
      "id": "5587035c-0262-4ffc-9435-89744d01ba8f",
      "name": "Analyze Job Data & Generate Response",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        2000,
        512
      ],
      "parameters": {
        "text": "={{ $json.data }}",
        "options": {
          "systemMessage": "You are an expert Job Curator and Social Media Manager for a high-quality professional Telegram channel. Your task is to process a raw JSON array of job listings, filter out low-quality/spam entries, and format the valid jobs into a Telegram-ready JSON response.\n\n1. Filtering Rules (Strictly Apply These):\n\nDeduplicate: If a job appears multiple times (e.g., once with just a link and once with a description), only keep the version with the full_job_description. Discard the others.\n\nRemove Low Quality: Discard entries that have no full_job_description or empty descriptions.\n\nRemove Spam/Gig-Work: Discard roles that appear to be low-tier \"gig\" work, content mills, or surveys (e.g., \"IAPWE\", \"Omni Interactions\", \"Student Representatives\", or generic \"Freelance Writer\" roles with no clear company identity).\n\nSalary Threshold: If a salary is listed as under $20/hour, discard it (unless it is a clear internship at a reputable tech company).\n\n2. Formatting Rules:\n\nOutput Structure: You must output a single JSON array of objects. Each object must have the keys: text, parse_mode, and disable_web_page_preview.\n\nParse Mode: Set parse_mode to \"HTML\".\n\nAllowed Tags: Use only the following HTML tags supported by Telegram: <b>, <i>, <a>, <code>. Do NOT use Markdown (**, __, [ ]).\n\nContent Layout:\n\nHeadline: <b>Job Title</b>\n\nMeta: \ud83c\udfe2 Company: [Name] | \ud83d\udcb0 Salary: [Value] | \ud83d\udccd Location: [Value]\n\nSummary: A 1-2 sentence professional summary of the role.\n\nTags: \ud83d\udee0 Stack/Skills: [List 3-5 key skills]\n\nCTA: <a href=\"job_link\">\ud83d\udd17 Apply Here</a>\n\n3. Output Constraints:\n\nNo Markdown Blocks: Do not output code blocks (like ```json). Output raw text starting with [ and ending with ].\n\nJSON Validity: Ensure the output is valid, parsable JSON. Escape all special characters (like quotes) inside the JSON strings correctly.\n\nExample Output Format: [ { \"text\": \"<b>Senior Developer</b>\\n\\n\ud83c\udfe2 <b>Company:</b> TechCorp\\n\ud83d\udcb0 <b>Salary:</b> $150k\\n\\n<b>About:</b> Leading the backend team.\\n\\n\ud83d\udee0 <b>Stack:</b> Java, AWS\\n\\n<a href=\"http://link.com\">\ud83d\udd17 Apply Here</a>\", \"parse_mode\": \"HTML\", \"disable_web_page_preview\": true } ]"
        },
        "promptType": "define",
        "hasOutputParser": true
      },
      "typeVersion": 3
    },
    {
      "id": "8f1020f5-850e-4a66-85c1-e77c2f54da17",
      "name": "Split Generated Job Items",
      "type": "n8n-nodes-base.code",
      "position": [
        2544,
        512
      ],
      "parameters": {
        "jsCode": "// 1. Get the data using the path from your first message\n// We access .output directly because your data shows it as an array, not a string inside .string\nconst rawData = $input.first().json.output;\n\nlet parsedData;\n\n// Check if the data exists before proceeding\nif (!rawData) {\n    // Throw an error to stop the workflow if data is missing\n    throw new Error(\"Input is empty or missing at the specified path: $input.first().json.output\");\n}\n\n// 2. Handle the data format\n// If n8n has already parsed it as an array (standard behavior), we use it directly.\n// If it happens to be a string (edge case), we parse it.\nif (typeof rawData === 'string') {\n    try {\n        parsedData = JSON.parse(rawData);\n    } catch (error) {\n        throw new Error(`Failed to parse JSON string: ${error.message}`);\n    }\n} else {\n    parsedData = rawData;\n}\n\n// 3. Ensure the data is an array\nif (!Array.isArray(parsedData)) {\n    throw new Error('The data at json.output is not an array. It cannot be split into multiple items.');\n}\n\n// 4. Map the array of objects into the n8n item format { json: object }\n// Each element in this array will be treated as a new item by n8n\nconst outputItems = parsedData.map(item => ({\n    json: item,\n}));\n\n// 5. Return the new array of items\nreturn outputItems;"
      },
      "typeVersion": 2
    },
    {
      "id": "80104868-b954-43f9-ad18-38642658474f",
      "name": "Avoid Rate Limits",
      "type": "n8n-nodes-base.wait",
      "position": [
        2960,
        480
      ],
      "parameters": {},
      "typeVersion": 1.1
    },
    {
      "id": "c5c416dd-e13c-42b7-9aaf-b6b612da92cb",
      "name": "Send Travel List to User Channel",
      "type": "n8n-nodes-base.telegram",
      "position": [
        3168,
        512
      ],
      "parameters": {
        "text": "={{ $json.text }}",
        "chatId": "parameters.chatId==@Channel ID (Use Channel ID or Chat ID)",
        "additionalFields": {
          "parse_mode": "={{ $json.parse_mode }}",
          "disable_web_page_preview": "={{ $json.disable_web_page_preview }}"
        }
      },
      "credentials": {
        "telegramApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "e9b06a2a-1ae8-42d4-8922-abadece65997",
      "name": "Schedule Daily",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        832,
        512
      ],
      "parameters": {
        "rule": {
          "interval": [
            {}
          ]
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "c0a06e5a-f431-4b10-82d5-a7c03aa6c5f6",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        832,
        -144
      ],
      "parameters": {
        "color": 6,
        "width": 672,
        "height": 384,
        "content": "@[youtube](DEBF0ILrM5E)"
      },
      "typeVersion": 1
    },
    {
      "id": "d9933a15-6076-40e4-87eb-460ec5c4751f",
      "name": "Documentation",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        432,
        -144
      ],
      "parameters": {
        "width": 380,
        "height": 520,
        "content": "## \u26a1 Workflow Overview & Setup\n\n**Summary:** This automation daily scrapes remote job listings from SimplyHired and Remotive using BrowserAct, uses AI to filter spam and curate high-quality roles, and auto-posts them to a Telegram channel.\n\n### Requirements\n* **Credentials:** BrowserAct, Google Gemini (PaLM), Telegram.\n* **Mandatory:** BrowserAct API (Template: **Automated Remote Job Fetching & Filtering for Telegram Feed**)\n\n### How to Use\n1. **Credentials:** Set up your BrowserAct, Google Gemini, and Telegram Bot API keys in n8n.\n2. **BrowserAct Template:** Ensure you have the **Automated Remote Job Fetching & Filtering for Telegram Feed** template saved in your BrowserAct account.\n3. **Configuration:** Update the Telegram node with your target Channel ID (e.g., `@yourchannel`).\n\n### Need Help?\n[How to Find Your BrowserAct API Key & Workflow ID](https://docs.browseract.com)\n[How to Connect n8n to BrowserAct](https://docs.browseract.com)\n[How to Use & Customize BrowserAct Templates](https://docs.browseract.com)"
      },
      "typeVersion": 1
    },
    {
      "id": "53de33d7-4c6c-498d-bbad-170c2426dd46",
      "name": "Step 1 Explanation",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        832,
        256
      ],
      "parameters": {
        "color": 7,
        "width": 764,
        "height": 124,
        "content": "### \ud83d\udd75\ufe0f Step 1: Multi-Source Scraping\n\nThe workflow triggers daily to run dual BrowserAct sessions, scraping the latest remote job listings from SimplyHired and Remotive. The raw data from both sources is then parsed into individual job items for processing."
      },
      "typeVersion": 1
    },
    {
      "id": "25aeb140-51d0-4858-826e-73f78bc89056",
      "name": "Step 2 Explanation",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1680,
        256
      ],
      "parameters": {
        "color": 7,
        "width": 780,
        "height": 124,
        "content": "### \ud83e\udde0 Step 2: AI Curation & Quality Control\n\nAn AI agent aggregates the job feeds, performing deduplication and strict filtering. It discards low-tier gig work, spam, and roles below a specific salary threshold while generating professional HTML-formatted posts for Telegram."
      },
      "typeVersion": 1
    },
    {
      "id": "eb6db87d-a37f-4850-a86f-074f9803592c",
      "name": "Step 3 Explanation",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2544,
        256
      ],
      "parameters": {
        "color": 7,
        "width": 776,
        "height": 124,
        "content": "### \ud83d\ude80 Step 3: Throttled Delivery\n\nThe curated job list is split into individual items and sent sequentially to the Telegram channel. A wait node is included between posts to respect Telegram's rate limits and ensure consistent delivery."
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "versionId": "a0056f52-2c96-4117-8150-473d54edcf8c",
  "connections": {
    "Fix Output": {
      "ai_languageModel": [
        [
          {
            "node": "Structured Output Parser",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Google Gemini": {
      "ai_languageModel": [
        [
          {
            "node": "Analyze Job Data & Generate Response",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Schedule Daily": {
      "main": [
        [
          {
            "node": "Scrape Jobs Data (Remotive)",
            "type": "main",
            "index": 0
          },
          {
            "node": "Scrape Jobs Data (SimplyHired)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Loop Over Items": {
      "main": [
        [],
        [
          {
            "node": "Avoid Rate Limits",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Avoid Rate Limits": {
      "main": [
        [
          {
            "node": "Send Travel List to User Channel",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Branch Outputs": {
      "main": [
        [
          {
            "node": "Analyze Job Data & Generate Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Splitting Remotive Data": {
      "main": [
        [
          {
            "node": "Wait for Both Path Outputs",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Structured Output Parser": {
      "ai_outputParser": [
        [
          {
            "node": "Analyze Job Data & Generate Response",
            "type": "ai_outputParser",
            "index": 0
          }
        ]
      ]
    },
    "Split Generated Job Items": {
      "main": [
        [
          {
            "node": "Loop Over Items",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Splitting SimplyHired Data": {
      "main": [
        [
          {
            "node": "Wait for Both Path Outputs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait for Both Path Outputs": {
      "main": [
        [
          {
            "node": "Merge Branch Outputs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Scrape Jobs Data (Remotive)": {
      "main": [
        [
          {
            "node": "Splitting Remotive Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Scrape Jobs Data (SimplyHired)": {
      "main": [
        [
          {
            "node": "Splitting SimplyHired Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Travel List to User Channel": {
      "main": [
        [
          {
            "node": "Loop Over Items",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Analyze Job Data & Generate Response": {
      "main": [
        [
          {
            "node": "Split Generated Job Items",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}