{
  "name": "Job search",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "40 7,12,16,20 * * *"
            }
          ]
        }
      },
      "id": "15792769-0aa3-4c66-aaa7-9f0c5fa07837",
      "name": "Schedule: four times a day",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -620,
        400
      ],
      "notesInFlow": true,
      "notes": "Runs at 07:40, 12:40, 16:40 and 20:40. Four sweeps a day is enough: a posting that\nis worth applying to is still there six hours later, and the applicant count is the\nthing that moves, not the listing.\n\nThe odd minute is deliberate. Offset this from any other browser driven workflow you\nrun. Two workflows sharing one browser profile can deadlock: both wait on the same\nsession, neither releases it, and both runs time out. Keep at least thirty minutes\nbetween their start times, and stay off the exact hour, where most default schedules\ncluster."
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "980d746a-2d83-47a0-920f-a7f90ac853c0",
              "name": "keywords",
              "value": "={{ [\"software engineer\", \"backend engineer\", \"platform engineer\"] }}",
              "type": "array"
            },
            {
              "id": "ebc8744d-e566-46dc-8b69-2f0622ff03f3",
              "name": "excludeKeywords",
              "value": "={{ [\"intern\", \"internship\", \"unpaid\", \"commission only\", \"equity only\"] }}",
              "type": "array"
            },
            {
              "id": "a036d9c7-63ce-4079-8a1f-3f3c6bda0cf5",
              "name": "locations",
              "value": "={{ [\"Amsterdam\", \"Dublin\", \"Lisbon\"] }}",
              "type": "array"
            },
            {
              "id": "656703dc-b18e-4ba0-a0fe-8e6adb33f1c2",
              "name": "remoteOnly",
              "value": false,
              "type": "boolean"
            },
            {
              "id": "5a45fab8-c879-428d-976d-04ef5b7cdbc2",
              "name": "maxAgeDays",
              "value": 7,
              "type": "number"
            },
            {
              "id": "26b3e24b-0459-433c-933e-d363102c1b22",
              "name": "promotedMaxAgeDays",
              "value": 2,
              "type": "number"
            },
            {
              "id": "aea967fe-4c29-493b-8680-c11b31bb084a",
              "name": "maxApplicants",
              "value": 50,
              "type": "number"
            },
            {
              "id": "a65c3ba8-6fff-4066-b6b1-59c8ee6bd810",
              "name": "seniorityAllow",
              "value": "={{ [\"junior\", \"mid\", \"senior\", \"staff\", \"lead\"] }}",
              "type": "array"
            },
            {
              "id": "983c2977-8c28-43f6-a282-4a408d72fb90",
              "name": "boardRssUrl",
              "value": "https://jobs.example.com/feed.rss",
              "type": "string"
            },
            {
              "id": "211f2e86-27a2-4bc5-a7dd-23f1eb790ba5",
              "name": "boardApiUrlA",
              "value": "https://api.example.com/v1/jobs",
              "type": "string"
            },
            {
              "id": "d8cb6a79-f71e-4186-8fec-67945dc0bba4",
              "name": "boardApiUrlB",
              "value": "https://boards.example.org/api/search",
              "type": "string"
            },
            {
              "id": "391c7678-6a7f-43e3-bda1-80856187fd7a",
              "name": "perBoardLimit",
              "value": 50,
              "type": "number"
            },
            {
              "id": "a6eb67a8-7f13-4396-b4ec-db44ca85a601",
              "name": "telegramChatId",
              "value": "",
              "type": "string"
            },
            {
              "id": "2f6dd488-4438-4e4d-869d-e608bc9211b6",
              "name": "sheetId",
              "value": "",
              "type": "string"
            },
            {
              "id": "b41bfb72-e32e-42bf-bd91-4a67ae6bb181",
              "name": "sheetTab",
              "value": "Job log",
              "type": "string"
            }
          ]
        },
        "includeOtherFields": false,
        "options": {}
      },
      "id": "b2c0d348-fc3d-4fe4-b0ae-8f51d827e25e",
      "name": "Settings",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        -400,
        400
      ],
      "notesInFlow": true,
      "notes": "Every knob lives here, so the rest of the workflow can be read without editing it.\nChange these values and nothing else.\n\nThe values shipped are neutral starting defaults, not a recommendation:\nmaxAgeDays 7 keeps a week of postings, maxApplicants 50 is the point where a\ngeneric application stops being read, and seniorityAllow is a list of levels you\nare willing to see rather than the level you are.\n\nboardRssUrl, boardApiUrlA and boardApiUrlB point at example.com on purpose. Put your\nown boards in. Check each board terms of use before you poll it, and keep\nperBoardLimit low enough that one sweep is a page of results, not a scrape.\n\ntelegramChatId and sheetId are blank so the workflow cannot post anywhere by\naccident on the first run. Fill them in before you activate it."
    },
    {
      "parameters": {
        "url": "={{ $('Settings').first().json.boardRssUrl }}",
        "options": {}
      },
      "id": "5d8fcf13-678a-42c3-89fa-2918f2ab4041",
      "name": "Fetch: RSS board",
      "type": "n8n-nodes-base.rssFeedRead",
      "typeVersion": 1.1,
      "position": [
        -160,
        140
      ],
      "onError": "continueRegularOutput",
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "notes": "Prefer a feed over an API where a board offers one. A feed is chronological, cheap to\npoll, and nothing in it is boosted, which makes its dates trustworthy.\n\nOn error this node continues rather than failing the run. One board being down should\nnot cost you the other two, and the normalise step drops the error item."
    },
    {
      "parameters": {
        "url": "={{ $('Settings').first().json.boardApiUrlA }}",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "q",
              "value": "={{ $('Settings').first().json.keywords.join(\" OR \") }}"
            },
            {
              "name": "location",
              "value": "={{ $('Settings').first().json.remoteOnly ? \"remote\" : ($('Settings').first().json.locations[0] || \"\") }}"
            },
            {
              "name": "limit",
              "value": "={{ $('Settings').first().json.perBoardLimit }}"
            }
          ]
        },
        "options": {
          "timeout": 20000
        }
      },
      "id": "716cd8a6-4dc2-4ce0-a2dd-f1b778f95e19",
      "name": "Fetch: JSON board A",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        -160,
        400
      ],
      "onError": "continueRegularOutput",
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "notes": "Only the first location is sent as a query parameter. Most board APIs take one location\nper call, and filtering the rest locally in the fit step is simpler than fanning out\none request per city and then having to merge and rate limit them.\n\nTimeout is 20 seconds. A board that is slower than that is usually rate limiting you,\nand the retry with a five second gap handles the transient case."
    },
    {
      "parameters": {
        "url": "={{ $('Settings').first().json.boardApiUrlB }}",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "query",
              "value": "={{ $('Settings').first().json.keywords.join(\" OR \") }}"
            },
            {
              "name": "remote",
              "value": "={{ $('Settings').first().json.remoteOnly }}"
            },
            {
              "name": "per_page",
              "value": "={{ $('Settings').first().json.perBoardLimit }}"
            }
          ]
        },
        "options": {
          "timeout": 20000
        }
      },
      "id": "fb9a89c0-9681-4b68-8123-a13ea8c24b6f",
      "name": "Fetch: JSON board B",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        -160,
        660
      ],
      "onError": "continueRegularOutput",
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "notes": "A second board with different parameter names, kept separate rather than merged into\none clever generic fetch. Boards change their field names without warning, and when\none breaks you want to see which node went red."
    },
    {
      "parameters": {
        "jsCode": "// An RSS item carries a headline, a link and a date, and almost never a company field,\n// so company and location are read out of the common \"Role at Company (City)\" title form.\n// When the title does not follow it, company and location stay empty and the fit filter\n// decides on the title alone rather than on a bad guess.\nconst out = [];\n\nfor (const item of $input.all()) {\n  const raw = item.json;\n  const link = raw.link || raw.guid || '';\n\n  // A failed fetch continues into this node as an error item. No link means no listing.\n  if (!link) continue;\n\n  const title = String(raw.title || '').trim();\n  const atSplit = title.split(/\\s+at\\s+/i);\n  const bracketed = title.match(/\\(([^)]+)\\)\\s*$/);\n\n  out.push({\n    json: {\n      title: (atSplit[0] || title).replace(/\\s*\\([^)]*\\)\\s*$/, '').trim(),\n      company: (atSplit[1] || '').replace(/\\s*\\([^)]*\\)\\s*$/, '').trim(),\n      url: link,\n      postedAt: raw.isoDate || raw.pubDate || '',\n      applicants: null,\n      location: bracketed ? bracketed[1].trim() : '',\n      remote: /remote/i.test(title),\n      promoted: false,\n      dateExact: Boolean(raw.isoDate || raw.pubDate),\n      source: 'rss-board'\n    }\n  });\n}\n\nreturn out;"
      },
      "id": "855369c2-b3aa-4d39-a91d-37732ce9b894",
      "name": "Normalise: RSS board",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        80,
        140
      ],
      "notes": "Every board gets its own mapper, and all three emit the same seven fields plus the\nthree flags the filters need: title, company, url, postedAt, applicants, location,\nsource, and then remote, promoted and dateExact.\n\nMapping per board rather than one shared mapper with shape detection is on purpose.\nDetection guesses, and a wrong guess writes silent rubbish into the sheet."
    },
    {
      "parameters": {
        "jsCode": "// One HTTP response holds a whole page of results, so flatten whichever list key the\n// board used before mapping. An unrecognised shape yields no items rather than a page of\n// empty rows, which is the difference between a quiet run and a poisoned sheet.\nconst LIST_KEYS = ['results', 'jobs', 'data', 'items', 'hits', 'postings'];\n\nconst rows = [];\nfor (const item of $input.all()) {\n  const body = item.json;\n  if (Array.isArray(body)) { rows.push(...body); continue; }\n  const key = LIST_KEYS.find((k) => Array.isArray(body[k]));\n  if (key) rows.push(...body[key]);\n}\n\nreturn rows.map((r) => {\n  const location = r.location && typeof r.location === 'object'\n    ? (r.location.name || r.location.city || '')\n    : (r.location || r.city || '');\n  const postedAt = r.published_at || r.created_at || r.postedAt || r.date_posted || '';\n\n  return {\n    json: {\n      title: String(r.title || r.name || r.position || '').trim(),\n      company: String(r.company || r.company_name || (r.organization && r.organization.name) || '').trim(),\n      url: r.url || r.absolute_url || r.apply_url || r.link || '',\n      postedAt: String(postedAt),\n      // Only a real number counts. A board that omits the field is unknown, not zero.\n      applicants: typeof r.applicants === 'number' ? r.applicants\n        : (typeof r.applicant_count === 'number' ? r.applicant_count : null),\n      location: String(location).trim(),\n      remote: r.remote === true || /remote/i.test(String(location)),\n      promoted: r.promoted === true || r.sponsored === true || r.featured === true,\n      // An absolute ISO date can be trusted. Anything else is treated as an estimate.\n      dateExact: /^\\d{4}-\\d{2}-\\d{2}/.test(String(postedAt)),\n      source: 'json-board-a'\n    }\n  };\n});"
      },
      "id": "2efbe0e3-4cc1-40d2-9cf9-6573cca4fc0a",
      "name": "Normalise: JSON board A",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        80,
        400
      ],
      "notes": "Field names are tried in order of how common they are, so this mapper survives most\nboard APIs unchanged. If your board uses different names, add them to the fallbacks\nhere rather than changing anything downstream.\n\napplicants is null when the board does not publish the number. Null and zero mean very\ndifferent things to the applicant count filter, so they are never conflated."
    },
    {
      "parameters": {
        "jsCode": "// This board publishes ages as text (\"3 days ago\") instead of dates. The text is turned\n// into a timestamp so freshness can be compared across boards, but the result is marked\n// inexact, because \"3 days ago\" on a board that rounds could be anything from 3 to 6 days.\nfunction relativeToDate(text) {\n  const m = String(text || '').match(/(\\d+)\\s*(minute|hour|day|week|month)/i);\n  if (!m) return '';\n  const hours = { minute: 1 / 60, hour: 1, day: 24, week: 168, month: 720 }[m[2].toLowerCase()];\n  return new Date(Date.now() - Number(m[1]) * hours * 3600000).toISOString();\n}\n\nconst LIST_KEYS = ['results', 'jobs', 'data', 'items', 'hits', 'postings'];\n\nconst rows = [];\nfor (const item of $input.all()) {\n  const body = item.json;\n  if (Array.isArray(body)) { rows.push(...body); continue; }\n  const key = LIST_KEYS.find((k) => Array.isArray(body[k]));\n  if (key) rows.push(...body[key]);\n}\n\nreturn rows.map((r) => {\n  const absolute = r.posted_at || r.published_at || '';\n  const relative = r.posted_ago || r.age || r.listed || '';\n  const postedAt = absolute || relativeToDate(relative);\n\n  return {\n    json: {\n      title: String(r.title || r.role || '').trim(),\n      company: String(r.company || r.employer || '').trim(),\n      url: r.url || r.link || '',\n      postedAt: String(postedAt),\n      applicants: typeof r.applicants === 'number' ? r.applicants\n        : (typeof r.num_applicants === 'number' ? r.num_applicants : null),\n      location: String(r.location || r.place || '').trim(),\n      remote: r.is_remote === true || /remote/i.test(String(r.location || '')),\n      promoted: r.is_promoted === true || r.sponsored === true,\n      dateExact: /^\\d{4}-\\d{2}-\\d{2}/.test(String(absolute)),\n      source: 'json-board-b'\n    }\n  };\n});"
      },
      "id": "11315a87-1f6e-48cb-8be1-9e8f8a97bc3a",
      "name": "Normalise: JSON board B",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        80,
        660
      ],
      "notes": "Boards that publish \"3 days ago\" instead of a date are the reason dateExact exists.\nThe relative text is converted so freshness is comparable across boards, and flagged\nas inexact so the freshness filter can hold it to a tighter limit."
    },
    {
      "parameters": {
        "mode": "append",
        "numberInputs": 3,
        "options": {}
      },
      "id": "0b7863ba-b6a8-4284-80fa-cd0f10e8f13f",
      "name": "Merge boards",
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3,
      "position": [
        320,
        400
      ],
      "notes": "Append, not combine. The same job posted to two boards is a genuine duplicate and is\nremoved later by url, but merging on a key here would silently drop the copy that has\nthe better metadata."
    },
    {
      "parameters": {
        "jsCode": "// Age and stated seniority are derived once, here, so each filter downstream stays a\n// one line condition and the priority order is readable on the canvas.\nconst SENIORITY = ['intern', 'junior', 'mid', 'senior', 'staff', 'principal', 'lead', 'head', 'director'];\nconst now = Date.now();\n\nreturn $input.all().map((item) => {\n  const j = item.json;\n  const parsed = j.postedAt ? Date.parse(j.postedAt) : NaN;\n  const title = String(j.title || '').toLowerCase();\n\n  return {\n    json: {\n      ...j,\n      postedAt: Number.isNaN(parsed) ? '' : new Date(parsed).toISOString(),\n      // null, not 0, when there is no usable date. The freshness filter rejects on the\n      // empty postedAt instead, so an unknown age can never pass as \"posted today\".\n      ageDays: Number.isNaN(parsed) ? null : Math.floor((now - parsed) / 86400000),\n      // Empty when the title states no level, which is common and is not a reason to skip.\n      seniority: SENIORITY.find((s) => new RegExp('\\\\b' + s + '\\\\b').test(title)) || '',\n      seenAt: new Date(now).toISOString()\n    }\n  };\n});"
      },
      "id": "21971336-41b3-453c-9af5-a8b3c7c10152",
      "name": "Derive age and level",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        540,
        400
      ],
      "notes": "Derivation is separated from the per board mappers so that a board changing its field\nnames is a one node fix, and a change to how age is judged is also a one node fix."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": false,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "2f0bacd3-02cb-46f3-8ea9-c53debb3db3d",
              "leftValue": "={{ $('Settings').first().json.keywords.some(k => (($json.title || \"\") + \" \" + ($json.company || \"\")).toLowerCase().includes(String(k).toLowerCase())) }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            },
            {
              "id": "1610fc02-d824-49ac-8858-4e460b38bc32",
              "leftValue": "={{ !$('Settings').first().json.excludeKeywords.some(k => (($json.title || \"\") + \" \" + ($json.company || \"\")).toLowerCase().includes(String(k).toLowerCase())) }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            },
            {
              "id": "89e8ec72-7113-4f7e-b1ea-90eb5f9d0557",
              "leftValue": "={{ $json.seniority === \"\" || $('Settings').first().json.seniorityAllow.map(s => String(s).toLowerCase()).includes($json.seniority) }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            },
            {
              "id": "61283521-346c-42a4-ab4c-2616658ed33b",
              "leftValue": "={{ $('Settings').first().json.remoteOnly ? $json.remote === true : ($json.remote === true || $('Settings').first().json.locations.length === 0 || $('Settings').first().json.locations.some(l => String($json.location || \"\").toLowerCase().includes(String(l).toLowerCase()))) }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "6c743b26-3852-41bf-bf50-01d101b58698",
      "name": "Filter 1: Fit",
      "type": "n8n-nodes-base.filter",
      "typeVersion": 2.2,
      "position": [
        780,
        400
      ],
      "notes": "Fit runs first because it is the cheapest reject and the largest one. Most of what a\nboard returns is not the job you are looking for, and there is no point reasoning\nabout the freshness of a posting you would never open.\n\nThe four conditions, in order: the title or company mentions one of your keywords,\nnone of your excluded keywords appear, the stated level is one you allow, and the\nlocation matches or the posting is remote.\n\nA title that states no level passes. Plenty of good postings simply do not say, and\nrejecting silence would throw away more than it saves. Only a level that is stated\nand is outside your allow list is a reject.\n\nWith remoteOnly on, a city match is not enough: the posting has to actually say\nremote. Hybrid roles listed under a city are the most common false positive here."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": false,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "def8a901-8468-4b44-b689-8ba00b2b5c0b",
              "leftValue": "={{ $json.postedAt !== \"\" && $json.ageDays !== null }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            },
            {
              "id": "076107e3-54a3-44e2-a48b-515e3208978b",
              "leftValue": "={{ ($json.promoted === true || $json.dateExact !== true) ? $json.ageDays <= $('Settings').first().json.promotedMaxAgeDays : true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            },
            {
              "id": "4b5fe007-f5fc-470f-824b-fba9af9282a6",
              "leftValue": "={{ $json.ageDays <= $('Settings').first().json.maxAgeDays }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "fde13e43-55c2-4e47-8101-7d551d0ef008",
      "name": "Filter 2: Freshness",
      "type": "n8n-nodes-base.filter",
      "typeVersion": 2.2,
      "position": [
        1020,
        400
      ],
      "notes": "Freshness runs second. Applying to a four week old posting is not free: it costs the\nsame hour as a fresh one and it is read last, if at all.\n\nThe promoted listing trap is the reason this node has three conditions instead of one.\nA promoted or sponsored listing is re-surfaced by the board, and many boards restamp\nit when they do. The row shows a date from yesterday while the posting itself has been\nopen for weeks and already has hundreds of applicants. Treating that date as real is\nhow you end up applying to the oldest listings on the board while believing you are\nfirst. The same goes for a relative age such as \"3 days ago\" on a board that rounds.\n\nSo: a promoted listing, or one whose date was inferred rather than stated, has to\nclear the tighter promotedMaxAgeDays instead of maxAgeDays. If it is genuinely new it\nstill passes. If it is a warmed over listing it does not.\n\nA posting with no date at all is skipped, not passed. An absent date is missing\ninformation, and the boards that hide it are usually the ones with the most to hide.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": false,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "09271f60-9d61-47b6-94f0-70f0792e3cda",
              "leftValue": "={{ $json.applicants === null || $json.applicants <= $('Settings').first().json.maxApplicants }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "fd330e48-9031-498c-a057-336447fa3fd1",
      "name": "Filter 3: Applicant count",
      "type": "n8n-nodes-base.filter",
      "typeVersion": 2.2,
      "position": [
        1260,
        400
      ],
      "notes": "Applicant count runs last because it is the least reliable signal and the one most\nboards do not publish at all. Filtering on it earlier would reject postings that are\na good fit and genuinely fresh on the strength of a number that is often missing.\n\nAn unknown count passes. This is the opposite of the missing date rule one node up,\nand deliberately so: a missing date usually means the board is hiding age, while a\nmissing applicant count usually just means the board never had the field.\n\nOnly a count that is present and above your ceiling is a reject."
    },
    {
      "parameters": {
        "compare": "selectedFields",
        "fieldsToCompare": "url",
        "options": {}
      },
      "id": "e27aea8d-a721-493e-a411-d8ec6c255b1e",
      "name": "Dedupe within run",
      "type": "n8n-nodes-base.removeDuplicates",
      "typeVersion": 2,
      "position": [
        1500,
        400
      ],
      "notes": "Same sweep, two boards, one posting. Deduping inside the run first keeps the cross run\nhistory clean, because only one copy of the url is ever recorded as seen."
    },
    {
      "parameters": {
        "operation": "removeItemsSeenInPreviousExecutions",
        "logic": "removeItemsWithAlreadySeenKeyValues",
        "dedupeValue": "={{ $json.url }}",
        "options": {
          "historySize": 10000
        }
      },
      "id": "37786f86-6805-4cfe-9634-41be3f2529ae",
      "name": "Dedupe across runs",
      "type": "n8n-nodes-base.removeDuplicates",
      "typeVersion": 2,
      "position": [
        1740,
        400
      ],
      "notes": "Four sweeps a day means the same posting is found up to four times a day for a week.\nWithout this node the alert channel becomes noise within two days and gets muted, and\na muted channel is the same as no workflow at all.\n\nThe url is the key. Titles repeat across companies and postings get retitled, but a\nboard url is stable. History is capped at 10000 keys, which is roughly a year of\nsweeps at this volume."
    },
    {
      "parameters": {
        "jsCode": "// One message per sweep, not one per posting. Four sweeps a day times every match is\n// how a channel you read becomes a channel you mute.\nconst items = $input.all();\nconst MAX_LINES = 20;\n\nconst lines = items.slice(0, MAX_LINES).map((item, i) => {\n  const j = item.json;\n  const age = j.ageDays === 0 ? 'today' : j.ageDays + 'd old';\n  const applicants = j.applicants === null ? 'applicants not shown' : j.applicants + ' applicants';\n  const flag = j.promoted ? ' (promoted, age unverified)' : '';\n\n  return [\n    (i + 1) + '. ' + j.title + ' at ' + (j.company || 'unknown company'),\n    '   ' + [j.location || 'location not stated', age + flag, applicants].join(' | '),\n    '   ' + j.url\n  ].join('\\n');\n});\n\nconst header = items.length === 1 ? '1 new match' : items.length + ' new matches';\nconst footer = items.length > MAX_LINES\n  ? '\\n\\nAnd ' + (items.length - MAX_LINES) + ' more in the sheet.'\n  : '';\n\nreturn [{ json: { matchCount: items.length, message: header + '\\n\\n' + lines.join('\\n\\n') + footer } }];"
      },
      "id": "e25fb666-8251-4db9-af82-b332efe01646",
      "name": "Build digest",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1980,
        260
      ],
      "notes": "Runs once for all items, so it produces a single message. When nothing survives the\nfilters this node gets no items and never runs, which is the intended behaviour: a\nsweep that found nothing should say nothing.\n\nThe promoted flag is carried into the message rather than hidden. If a listing only\npassed because it claims to be new, you should see that before you spend an hour on it."
    },
    {
      "parameters": {
        "chatId": "={{ $('Settings').first().json.telegramChatId }}",
        "text": "={{ $json.message }}",
        "additionalFields": {
          "appendAttribution": false,
          "disable_web_page_preview": true
        }
      },
      "id": "3ee1f3e8-b8a8-4bbb-81db-26363504ddc9",
      "name": "Notify: Telegram",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        2220,
        260
      ],
      "credentials": {
        "telegramApi": {
          "name": "Telegram account"
        }
      },
      "notes": "Swap this for email or Slack if you prefer. The only thing that matters is that the\nnotification lands somewhere you already look, otherwise the workflow is a sheet you\nforget to open.\n\nLink previews are off. Twenty of them turn a digest into a wall."
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "value": "={{ $('Settings').first().json.sheetId }}",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "={{ $('Settings').first().json.sheetTab }}",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "seenAt": "={{ $json.seenAt }}",
            "title": "={{ $json.title }}",
            "company": "={{ $json.company }}",
            "location": "={{ $json.location }}",
            "postedAt": "={{ $json.postedAt }}",
            "ageDays": "={{ $json.ageDays }}",
            "applicants": "={{ $json.applicants === null ? \"not shown\" : $json.applicants }}",
            "promoted": "={{ $json.promoted }}",
            "source": "={{ $json.source }}",
            "url": "={{ $json.url }}",
            "status": "New"
          },
          "matchingColumns": [],
          "schema": []
        },
        "options": {}
      },
      "id": "96061d5f-57ec-4423-85b4-b932f0a2ce42",
      "name": "Append to job log",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        1980,
        560
      ],
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "Google Sheets account"
        }
      },
      "onError": "continueRegularOutput",
      "notes": "The sheet is the memory. It is what lets you answer \"did I already apply to this\" in\na month, and it is where the status column turns a feed into a pipeline.\n\nThis runs in parallel with the notification, not after it, so a sheet that is out of\nquota or renamed cannot swallow the alert. On error it continues for the same reason.\n\nCreate the tab with these headers in row one before the first run:\nseenAt, title, company, location, postedAt, ageDays, applicants, promoted, source,\nurl, status. promoted is logged so you can check later whether the promoted listings\nyou applied to ever answered."
    }
  ],
  "connections": {
    "Schedule: four times a day": {
      "main": [
        [
          {
            "node": "Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Settings": {
      "main": [
        [
          {
            "node": "Fetch: RSS board",
            "type": "main",
            "index": 0
          },
          {
            "node": "Fetch: JSON board A",
            "type": "main",
            "index": 0
          },
          {
            "node": "Fetch: JSON board B",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch: RSS board": {
      "main": [
        [
          {
            "node": "Normalise: RSS board",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch: JSON board A": {
      "main": [
        [
          {
            "node": "Normalise: JSON board A",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch: JSON board B": {
      "main": [
        [
          {
            "node": "Normalise: JSON board B",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalise: RSS board": {
      "main": [
        [
          {
            "node": "Merge boards",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalise: JSON board A": {
      "main": [
        [
          {
            "node": "Merge boards",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Normalise: JSON board B": {
      "main": [
        [
          {
            "node": "Merge boards",
            "type": "main",
            "index": 2
          }
        ]
      ]
    },
    "Merge boards": {
      "main": [
        [
          {
            "node": "Derive age and level",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Derive age and level": {
      "main": [
        [
          {
            "node": "Filter 1: Fit",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter 1: Fit": {
      "main": [
        [
          {
            "node": "Filter 2: Freshness",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter 2: Freshness": {
      "main": [
        [
          {
            "node": "Filter 3: Applicant count",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter 3: Applicant count": {
      "main": [
        [
          {
            "node": "Dedupe within run",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Dedupe within run": {
      "main": [
        [
          {
            "node": "Dedupe across runs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Dedupe across runs": {
      "main": [
        [
          {
            "node": "Build digest",
            "type": "main",
            "index": 0
          },
          {
            "node": "Append to job log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build digest": {
      "main": [
        [
          {
            "node": "Notify: Telegram",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "saveManualExecutions": true,
    "saveExecutionProgress": true
  },
  "pinData": {},
  "tags": [],
  "active": false,
  "meta": {
    "templateCredsSetupCompleted": false
  }
}
