每天手動刷新求職平台,在一堆職缺中尋找符合條件的目標,是不是讓你感到厭煩?
這篇透過自動化工具 n8n,打造一個專屬的職缺小助理,讓它每天定時幫你從 104 人力銀行撈取最新的職缺,並整理好後自動發送到你的 Discord
來到儀表板,新增一個流程「Create Workflow」
初始節點選擇排程「On a schedule」
設定想收到通知的時間,比如說每日早上 9 點
接下來到「設定」裡面調整時區
把時區的「Timezone」設定為台灣時間
接著去 104 搜尋職缺,並把網址複製下來,比如說「react、台北、新北、今日更新」的網址可能會長這樣
https://www.104.com.tw/jobs/search/?area=6001001000,6001002000&jobsource=joblist_search&keyword=react&mode=s&page=1&order=15&isnew=0&searchJobs=1
接著我們把網址改成像這樣的格式
https://www.104.com.tw/jobs/search/api/jobs?area=6001001000%2C6001002000&isnew=0&jobsource=m_joblist_search&keyword=react&mode=s&order=15&page=1&pagesize=20&searchJobs=1
回到 n8n,新增節點「HTTP Request」,方法「GET」,URL 貼上剛剛的網址,然後把「Send Headers」打開,填上底下的資料
Name:
User-Agent
Value:
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36
Name:
Referer
Value: 使用原本第一次複製的網址
https://www.104.com.tw/jobs/search/?area=6001001000,6001002000&jobsource=m_joblist_search&keyword=react&mode=s&page=1&order=15&searchJobs=1&isnew=0
從 API 拿到的資料很雜,我們需要用程式碼來篩選出我們真正想要的,並將其整理成美觀的格式
接著我們用「Code」節點來整理資料
程式碼填寫如下
const apiResult = items[0].json;
// --- 可調整的設定 ---
// Discord 字元限制,我們設定一個保守值以預留空間給頁首頁尾
const DISCORD_CHAR_LIMIT = 1950;
// --------------------
// 1. 取得職缺列表
const allJobs = apiResult.data;
if (!Array.isArray(allJobs) || allJobs.length === 0) {
console.log("今天沒有新的職缺。");
return [];
}
// 2. 進行篩選 (Filter)
const filteredJobs = allJobs.filter((job) => {
// --- 在這裡加入你的篩選條件 ---
const today = new Date();
// 將日期設為台北時區 (UTC+8) 的午夜零時
today.setHours(today.getHours() + 8);
const todayString = today.toISOString().slice(0, 10).replace(/-/g, "");
const isToday = job.appearDate === todayString;
const salaryIsOk = job.salaryLow >= 50000;
return isToday && salaryIsOk;
});
if (filteredJobs.length === 0) {
console.log("有抓到職缺,但沒有符合篩選條件的。");
return [];
}
// 3. 將篩選後的職缺進行格式化 (Map)
const allFormattedMessages = filteredJobs.map((job) => {
const formatSalary = (low, high) => {
if (low === 0 && high === 0) return "面議";
if (high === 9999999) return `${low.toLocaleString()} 以上`;
return `${low.toLocaleString()} - ${high.toLocaleString()}`;
};
const jobName = job.jobName;
const jobLink = job.link.job;
const custName = job.custName;
const custLink = job.link.cust;
const location = job.jobAddrNoDesc;
const salary = formatSalary(job.salaryLow, job.salaryHigh);
return `**職缺:** [${jobName}](${jobLink})\n**公司:** [${custName}](${custLink})\n**地區:** ${location}\n**薪資:** ${salary}`;
});
// 4. 處理長度限制並組合最終訊息
let currentLength = 0;
const includedMessages = [];
const separator = "\n\n---\n\n";
// 頁首會用到總數,所以先宣告
const header = `🔥 今天共有 ${filteredJobs.length} 個符合條件的新職缺!\n\n`;
currentLength += header.length;
for (const message of allFormattedMessages) {
// 預計算加入下一則訊息後的總長度
// 如果是第一則訊息,則不用加分隔線的長度
const potentialLength =
currentLength +
(includedMessages.length > 0 ? separator.length : 0) +
message.length;
if (potentialLength <= DISCORD_CHAR_LIMIT) {
includedMessages.push(message);
currentLength = potentialLength;
} else {
// 長度已達上限,停止加入更多訊息
break;
}
}
// 5. 組合最終訊息 (包含頁首和可能的頁尾)
let finalMessage = header + includedMessages.join(separator);
// 如果有職缺因為長度限制被捨棄,就加上頁尾提示
const omittedCount = filteredJobs.length - includedMessages.length;
if (omittedCount > 0) {
const footer = `\n\n...還有 ${omittedCount} 則職缺因長度限制未顯示。`;
finalMessage += footer;
}
return [
{
json: {
discordMessage: finalMessage,
},
},
];
下個節點選擇 Discord 的「Send a message」
「Connection Type」選擇「Webhook」,憑證的串接在之前的文章有撰寫過,這邊就不重複惹
Message 的欄位填寫,接著可以點選右上角的「Excute step」來試跑看看
{
{
$json.discordMessage;
}
}
Discord 會看到類似這樣的訊息就代表成功囉
完成後的畫布長這樣,也要記得到上方切換為「Active」來啟用哦
最後也附上完整的 JSON
{
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"triggerAtHour": 9
}
]
}
},
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [0, 0],
"id": "f2761f57-78fc-45d7-9823-be17de31c7ba",
"name": "Schedule Trigger"
},
{
"parameters": {
"url": "https://www.104.com.tw/jobs/search/api/jobs?area=6001001000%2C6001002000&isnew=0&jobsource=m_joblist_search&keyword=react&mode=s&order=15&page=1&pagesize=20&searchJobs=1",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "User-Agent",
"value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36"
},
{
"name": "Referer",
"value": "https://www.104.com.tw/jobs/search/?area=6001001000,6001002000&jobsource=m_joblist_search&keyword=react&mode=s&page=1&order=15&searchJobs=1&isnew=0"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [220, 0],
"id": "8c659e5b-aa5c-47c3-a893-66e5562118ba",
"name": "HTTP Request"
},
{
"parameters": {
"jsCode": "const apiResult = items[0].json;\n\n// --- 可調整的設定 ---\n// Discord 字元限制,我們設定一個保守值以預留空間給頁首頁尾\nconst DISCORD_CHAR_LIMIT = 1950; \n// --------------------\n\n\n// 1. 取得職缺列表\nconst allJobs = apiResult.data;\n\nif (!Array.isArray(allJobs) || allJobs.length === 0) {\n console.log(\"今天沒有新的職缺。\");\n return []; \n}\n\n// 2. 進行篩選 (Filter)\nconst filteredJobs = allJobs.filter(job => {\n // --- 在這裡加入你的篩選條件 ---\n const today = new Date();\n // 將日期設為台北時區 (UTC+8) 的午夜零時\n today.setHours(today.getHours() + 8);\n const todayString = today.toISOString().slice(0, 10).replace(/-/g, '');\n \n const isToday = job.appearDate === todayString;\n const salaryIsOk = job.salaryLow >= 50000; \n \n return isToday && salaryIsOk; \n});\n\nif (filteredJobs.length === 0) {\n console.log(\"有抓到職缺,但沒有符合篩選條件的。\");\n return [];\n}\n\n// 3. 將篩選後的職缺進行格式化 (Map)\nconst allFormattedMessages = filteredJobs.map(job => {\n const formatSalary = (low, high) => {\n if (low === 0 && high === 0) return \"面議\";\n if (high === 9999999) return `${low.toLocaleString()} 以上`;\n return `${low.toLocaleString()} - ${high.toLocaleString()}`;\n };\n\n const jobName = job.jobName;\n const jobLink = job.link.job;\n const custName = job.custName;\n const custLink = job.link.cust;\n const location = job.jobAddrNoDesc;\n const salary = formatSalary(job.salaryLow, job.salaryHigh);\n\n return `**職缺:** [${jobName}](${jobLink})\\n**公司:** [${custName}](${custLink})\\n**地區:** ${location}\\n**薪資:** ${salary}`;\n});\n\n// 4. 處理長度限制並組合最終訊息\nlet currentLength = 0;\nconst includedMessages = [];\nconst separator = '\\n\\n---\\n\\n';\n\n// 頁首會用到總數,所以先宣告\nconst header = `🔥 今天共有 ${filteredJobs.length} 個符合條件的新職缺!\\n\\n`;\ncurrentLength += header.length;\n\nfor (const message of allFormattedMessages) {\n // 預計算加入下一則訊息後的總長度\n // 如果是第一則訊息,則不用加分隔線的長度\n const potentialLength = currentLength + (includedMessages.length > 0 ? separator.length : 0) + message.length;\n \n if (potentialLength <= DISCORD_CHAR_LIMIT) {\n includedMessages.push(message);\n currentLength = potentialLength;\n } else {\n // 長度已達上限,停止加入更多訊息\n break;\n }\n}\n\n// 5. 組合最終訊息 (包含頁首和可能的頁尾)\nlet finalMessage = header + includedMessages.join(separator);\n\n// 如果有職缺因為長度限制被捨棄,就加上頁尾提示\nconst omittedCount = filteredJobs.length - includedMessages.length;\nif (omittedCount > 0) {\n const footer = `\\n\\n...還有 ${omittedCount} 則職缺因長度限制未顯示。`;\n finalMessage += footer;\n}\n\nreturn [{\n json: {\n discordMessage: finalMessage\n }\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [440, 0],
"id": "0b600252-5755-4f4f-b348-fdbd8dc823c1",
"name": "Code"
},
{
"parameters": {
"authentication": "webhook",
"content": "={{ $json.discordMessage }}",
"options": {}
},
"type": "n8n-nodes-base.discord",
"typeVersion": 2,
"position": [660, 0],
"id": "18deeae7-0f0b-461b-8306-aa9b4318a359",
"name": "Discord"
}
],
"connections": {
"Schedule Trigger": {
"main": [
[
{
"node": "HTTP Request",
"type": "main",
"index": 0
}
]
]
},
"HTTP Request": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
},
"Code": {
"main": [
[
{
"node": "Discord",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"meta": {
"templateCredsSetupCompleted": true
}
}