忘記在哪邊看到有人問,有沒有 PTT 每日簽到(登入)的 n8n 版?
於是就花了一點時間,讓 AI 寫了一段可以在 n8n JavaScript code node 跑起來的程式碼
但因為需要引入額外的庫,所以需要在 docker compose 還有設定上做一些小變動 😆
首先根據上一篇的 n8n v2 docker compose 為基礎,改為以下:
services:
n8n:
image: n8nio/n8n
container_name: n8n
restart: always
ports:
- "5678:5678" # Web UI 端口
volumes:
- n8n-data:/home/node/.n8n
depends_on:
n8n-redis:
condition: service_healthy
environment:
# --- 基本設定 & 時區 ---
- GENERIC_TIMEZONE=Asia/Taipei
- TZ=Asia/Taipei
# --- 資料庫維護 (SQLite) ---
- DB_SQLITE_VACUUM_ON_STARTUP=true # 啟動時清理資料庫,用於防止 SQLite 資料庫過大導致效能下降
- DB_SQLITE_POOL_SIZE=1 # 啟用 SQLite WAL 模式效能較高較可靠
- EXECUTIONS_DATA_PRUNE=true
- EXECUTIONS_DATA_MAX_AGE=168 # 保留 168 小時內的執行紀錄,視需求可上下調整
- EXECUTIONS_DATA_PRUNE_MAX_COUNT=5000 # 最多保留 5000 筆紀錄,視需求可上下調整
# --- 網路與安全 (反向代理設定) ---
# 開啟後,必須透過 HTTPS 存取,否則無法登入,內網 HTTP 使用就設定為 false
- N8N_SECURE_COOKIE=true
- WEBHOOK_URL=https://n8n.example.com/ # 根據自己網域修改,內網無網域這行可以換成內網 IP
- N8N_PROXY_HOPS=1 # 告訴 n8n 前面有一層 Proxy (如 Nginx/Caddy),如內網使用這行可以刪除
- N8N_TRUSTED_PROXIES=0.0.0.0/0 # 信任所有來源的 Proxy 標頭,如內網使用這行可以刪除
# --- 權限與安全性 ---
- N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true
- N8N_GIT_NODE_DISABLE_BARE_REPOS=true
- N8N_BLOCK_ENV_ACCESS_IN_NODE=false # false 代表允許 Node 節點讀取環境變數
# --- Task Runners 設定 ---
# 這是主節點設定,負責派發任務給 task-runners
- N8N_RUNNERS_ENABLED=true
- N8N_RUNNERS_MODE=external # 使用外部 Runner 模式
- N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0 # 監聽來自 Docker 內網的連線
- N8N_RUNNERS_AUTH_TOKEN=Your1-awEsome-P0ssWorD # 請務必更改為強密碼
- N8N_NATIVE_PYTHON_RUNNER=true # 啟用 Python 支援
task-runners:
image: n8nio/runners # 如有指定版號,runners 版本需與 n8n 本體對齊
container_name: n8n-runners
restart: always
depends_on:
- n8n
environment:
# --- Task Runners 設定 (Worker 端) ---
# 設定主節點的連線位置 (Docker 內部通訊)
- N8N_RUNNERS_TASK_BROKER_URI=http://n8n:5679
- N8N_RUNNERS_AUTH_TOKEN=Your1-awEsome-P0ssWorD # 需與上方 n8n 主節點內的一致
- N8N_RUNNERS_AUTO_SHUTDOWN_TIMEOUT=0 # 設定為 0 可避免冷啟動延遲
# 以下為新增的部分,將 runner-javascript 安裝的 modules 儲存下來,避免每次 pull 都要重新安裝
volumes:
- ./n8n-task-runners.json:/etc/n8n-task-runners.json:ro
- runners-js-node-modules:/opt/runners/task-runner-javascript/node_modules
# 啟動後檢查本文章 PTT AutoCheckin JS 所需的庫在不在,不在的話就安裝
entrypoint:
- /bin/sh
- -lc
- |
cd /opt/runners/task-runner-javascript
if [ ! -d node_modules/ssh2 ] || [ ! -d node_modules/iconv-lite ]; then
pnpm add ssh2 iconv-lite
fi
exec /usr/local/bin/task-runner-launcher javascript
redis:
image: redis:alpine
container_name: n8n-redis
restart: always
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
volumes:
- n8n-redis-data:/data
volumes:
n8n-data:
n8n-redis-data:
runners-js-node-modules:
主要是在 task-runners 做了一些之後 JS 執行環境的準備
接著在 docker-compose.yml 同個路徑下新增一個 n8n-task-runners.json 的檔案,因為 n8n task runner 預設是不允許掛其他庫的
我們要將所需的庫加到允許清單內,才能在 n8n code node 環境中使用
{
"task-runners": [
{
"runner-type": "javascript",
"workdir": "/home/runner",
"command": "/usr/local/bin/node",
"args": [
"--disallow-code-generation-from-strings",
"--disable-proto=delete",
"/opt/runners/task-runner-javascript/dist/start.js"
],
"health-check-server-port": "5681",
"allowed-env": [
"PATH",
"GENERIC_TIMEZONE",
"NODE_OPTIONS",
"N8N_RUNNERS_AUTO_SHUTDOWN_TIMEOUT",
"N8N_RUNNERS_TASK_TIMEOUT",
"N8N_RUNNERS_MAX_CONCURRENCY",
"N8N_SENTRY_DSN",
"N8N_VERSION",
"ENVIRONMENT",
"DEPLOYMENT_NAME",
"HOME"
],
"env-overrides": {
"NODE_FUNCTION_ALLOW_BUILTIN": "net,stream,events",
"NODE_FUNCTION_ALLOW_EXTERNAL": "ssh2,iconv-lite",
"N8N_RUNNERS_HEALTH_CHECK_SERVER_HOST": "0.0.0.0"
}
},
{
"runner-type": "python",
"workdir": "/home/runner",
"command": "/opt/runners/task-runner-python/.venv/bin/python",
"args": ["-m", "src.main"],
"health-check-server-port": "5682",
"allowed-env": [
"PATH",
"N8N_RUNNERS_LAUNCHER_LOG_LEVEL",
"N8N_RUNNERS_AUTO_SHUTDOWN_TIMEOUT",
"N8N_RUNNERS_TASK_TIMEOUT",
"N8N_RUNNERS_MAX_CONCURRENCY",
"N8N_SENTRY_DSN",
"N8N_VERSION",
"ENVIRONMENT",
"DEPLOYMENT_NAME"
],
"env-overrides": {
"PYTHONPATH": "/opt/runners/task-runner-python",
"N8N_RUNNERS_STDLIB_ALLOW": "",
"N8N_RUNNERS_EXTERNAL_ALLOW": ""
}
}
]
}
上面這個設定檔案參考自官方: https://github.com/n8n-io/n8n/blob/master/docker/images/runners/n8n-task-runners.json
主要是允許 net,stream,events 與 ssh2,iconv-lite 外部庫的引入
以上兩個步驟弄好之後,就重新啟動一次 docker 讓新設定生效 😈
docker compose down && docker compose up -d
稍等一下讓你的 n8n 順利啟動後,就將下面的 n8n 設定內容拷貝,然後直接貼到你的 n8n 畫布裡
{
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"triggerAtHour": 6
}
]
}
},
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.3,
"position": [
0,
0
],
"id": "abfeb845-2631-4cf3-ada8-f80089171de0",
"name": "Schedule Trigger"
},
{
"parameters": {
"chatId": "0000000000",
"text": "=#PTTAutoLogin <b>結果:</b> {{ $json.status === 'success' ? '✅' : ($json.status === 'auth_failed' ? '❌' : '⚠️') }}\n\n<b>狀態</b>:<code>{{$json.status}}</code>\n<b>訊息</b>:{{$json.message}}\n<b>帳號</b>:<code>{{($json.logs[0] || '').replace('帳號讀取結果: ','')}}</code>\n\n<b>站內信</b>:\n{{ $json.metrics.hasNewMail ? '📨 有新信' : '📭 無新信' }}\n\n<b>上次登入 IP</b>:<code>{{$json.metrics.lastLoginIp || '未偵測到'}}</code>\n<b>時間</b>:<code>{{ $now.toFormat(\"yyyy-LL-dd HH:mm:ss\") }}</code>\n\n<b>關鍵事件:</b>\n<blockquote expandable>{{\n ($json.logs || [])\n .filter(l => !String(l).startsWith('[step '))\n .filter(l => [\n 'SSH 連線','送出帳號','送出密碼',\n '跳過公告','處理重複登入','已到主選單',\n '嘗試登出','登出確認','auth','timeout','error'\n ].some(k => String(l).includes(k)))\n .join('\\n')\n}}</blockquote>",
"additionalFields": {
"appendAttribution": false,
"disable_notification": true,
"parse_mode": "HTML"
}
},
"type": "n8n-nodes-base.telegram",
"typeVersion": 1.2,
"position": [
672,
0
],
"id": "4e7ccd32-ad0c-4fe3-8053-e8d450b8f93b",
"name": "Send a text message",
"webhookId": "54a89925-3a5f-474c-b79b-42fb5de662b1",
"credentials": {
"telegramApi": {
"id": "sh1pxFPxZUtAxPR1",
"name": "Telegram bot"
}
}
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "c3f9378a-543c-4133-a2fc-53ab882e576a",
"name": "ptt_username",
"value": "your-username",
"type": "string"
},
{
"id": "d861f4d4-e1b5-49f1-8923-bc61404397aa",
"name": "ptt_password",
"value": "your-password",
"type": "string"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
224,
0
],
"id": "47dc1ee3-17ff-4489-a621-3414ed8a0122",
"name": "ptt 帳號密碼"
},
{
"parameters": {
"jsCode": "const { Client } = require('ssh2');\nconst iconv = require('iconv-lite');\n\nconst logs = [];\nconst addLog = (msg) => {\n console.log(msg);\n logs.push(msg);\n};\n\nfunction getInputJson() {\n if (typeof items !== 'undefined' && Array.isArray(items) && items.length) {\n const first = items[0];\n if (first && typeof first === 'object') {\n if (first.json && typeof first.json === 'object') return first.json;\n return first;\n }\n }\n if (typeof item !== 'undefined' && item && typeof item === 'object') {\n if (item.json && typeof item.json === 'object') return item.json;\n return item;\n }\n return {};\n}\n\n// === 讀取帳密 ===\nconst input = getInputJson();\nconst username = String(input.ptt_username || '').trim();\nconst password = String(input.ptt_password || '').trim();\n\naddLog(`帳號讀取結果: ${username ? username : '(空)'}`);\naddLog(`密碼讀取結果: ${password ? '已成功取得' : '(空)'}`);\n\nif (!username || !password) {\n return [{ json: { status: 'error', message: 'Missing credentials: ptt_username / ptt_password', logs } }];\n}\n\n// === 工具 ===\nconst ANSI_RE = /(?:\\x1B[@-Z\\\\-_]|\\x1B\\[[0-?]*[ -/]*[@-~])/g;\nconst stripAnsi = (s) => String(s || '').replace(ANSI_RE, '').replace(/\\x00/g, '');\nconst seen = (text, ...keys) => keys.some((k) => text.includes(k));\nconst sleep = (ms) => new Promise((r) => setTimeout(r, ms));\n\n// Runner 超時保護\nconst DEADLINE_MS = 52_000;\nconst startAt = Date.now();\nconst timeLeftMs = () => DEADLINE_MS - (Date.now() - startAt);\n\n// === 讀取畫面 ===\nfunction makeReader(stream, onText) {\n let buf = Buffer.alloc(0);\n\n stream.on('data', (chunk) => {\n buf = Buffer.concat([buf, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);\n });\n\n const readAvailable = async (maxWaitMs = 350) => {\n const end = Date.now() + maxWaitMs;\n while (Date.now() < end && timeLeftMs() > 0) {\n await sleep(30);\n }\n if (!buf.length) return '';\n const out = buf;\n buf = Buffer.alloc(0);\n const decoded = iconv.decode(out, 'cp950');\n const cleaned = stripAnsi(decoded);\n if (typeof onText === 'function' && cleaned) onText(cleaned);\n return cleaned;\n };\n\n return { readAvailable };\n}\n\n// === 主選單判斷 ===\nfunction isMainMenu(text) {\n const t = String(text || '');\n const hits = [\n /\\(A\\)nnounce/i,\n /\\(F\\)avorite/i,\n /\\(C\\)lass/i,\n /\\(M\\)ail/i,\n /\\(G\\)oodbye/i,\n /離開,再見/,\n ].filter((re) => re.test(t)).length;\n\n return hits >= 2 || seen(t, '主功能表', '主選單', '【主功能表】', '功能表', 'Main Menu');\n}\n\n// === 解析:上次登入 IP + 是否有新信 ===\nfunction extractMetrics(text) {\n const t = String(text || '');\n\n // 上次登入 IP\n const lastLoginIp =\n (t.match(/上次您是從\\s*([0-9]{1,3}(?:\\.[0-9]{1,3}){3})\\s*連往本站/) || [])[1] ||\n (t.match(/上次(?:上站|登入).*?([0-9]{1,3}(?:\\.[0-9]{1,3}){3})/) || [])[1] ||\n '';\n\n // 主選單 banner:你有新信件\n const hasNewMail = /你有新信件|你有新信/.test(t);\n\n return { lastLoginIp, hasNewMail };\n}\n\n// === 主流程 ===\nasync function runPttSsh() {\n const host = 'ptt.cc';\n const port = 22;\n\n // PTT SSH gate:bbs/bbs\n const sshUser = 'bbs';\n const sshPass = 'bbs';\n\n addLog(`SSH 連線至 ${sshUser}@${host}:${port} ...`);\n\n let transcript = '';\n\n return await new Promise((resolve) => {\n const conn = new Client();\n\n const finish = (status, message, metrics) => {\n try { conn.end(); } catch {}\n resolve({ status, message, metrics });\n };\n\n conn.on('ready', () => {\n conn.shell({ term: 'xterm-256color', cols: 80, rows: 24 }, async (err, stream) => {\n if (err) return finish('error', String(err), extractMetrics(transcript));\n\n const { readAvailable } = makeReader(stream, (txt) => { transcript += txt + '\\n'; });\n\n const sendKeys = (s) => { try { stream.write(s); } catch {} };\n const sendLine = (s) => sendKeys(String(s) + '\\r');\n\n let screen = '';\n\n // 1) 喚醒直到看到 ID\n for (let i = 0; i < 10 && timeLeftMs() > 0; i++) {\n screen += await readAvailable(350);\n if (seen(screen, 'ID', '代號', '請輸入代號')) break;\n sendKeys('\\r');\n }\n\n if (!seen(screen, 'ID', '代號', '請輸入代號')) {\n addLog(`最後畫面片段: ${(screen || '').slice(-200)}`);\n return finish('timeout', '無法取得登入畫面(未出現 ID/代號 提示)', extractMetrics(transcript));\n }\n\n // 2) 帳密\n addLog('送出帳號...');\n sendLine(username);\n await sleep(250);\n await readAvailable(350);\n\n addLog('送出密碼...');\n sendLine(password);\n await sleep(450);\n\n // 多讀幾次,抓 banner\n for (let i = 0; i < 3 && timeLeftMs() > 0; i++) {\n screen += await readAvailable(700);\n await sleep(120);\n }\n\n if (seen(screen, '不對', '密碼錯誤')) {\n return finish('auth_failed', '帳號或密碼錯誤', extractMetrics(transcript));\n }\n\n // 3) 公告 / 中斷頁 → 主選單\n for (let step = 0; step < 14 && timeLeftMs() > 0; step++) {\n screen += await readAvailable(450);\n\n if (seen(screen, '重複登入', '刪除其他重複登入', '是否刪除')) {\n addLog('處理重複登入:送 y');\n sendLine('y');\n screen = '';\n continue;\n }\n\n if (seen(screen, '任意鍵', '按任意鍵', '請按任意鍵', '按任意鍵繼續')) {\n addLog('跳過公告:送 Space');\n sendKeys(' ');\n screen = '';\n continue;\n }\n\n if (isMainMenu(screen)) {\n addLog('已到主選單');\n break;\n }\n\n if (step === 6 || step === 10) {\n addLog('嘗試解除未知頁:送 q + Ctrl+C');\n sendKeys('q');\n sendKeys('\\x03');\n sendKeys('\\r');\n screen = '';\n }\n }\n\n // ★ 解析新信 & IP\n const metrics = extractMetrics(transcript);\n\n // 4) 登出 g\n addLog('嘗試登出:送 g');\n sendLine('g');\n await sleep(250);\n screen = await readAvailable(650);\n\n if (seen(screen, '確定', '是否', 'y/n', 'Y/N', '要離開', '離開')) {\n addLog('登出確認:送 y');\n sendLine('y');\n await sleep(200);\n await readAvailable(450);\n } else {\n addLog('未偵測到確認提示,仍補送 y');\n sendLine('y');\n await sleep(150);\n await readAvailable(350);\n }\n\n try { stream.end(); } catch {}\n return finish('success', 'PTT SSH 登入流程完成(已嘗試登出並關閉連線)', metrics);\n });\n });\n\n conn.on('error', (e) => finish('error', String(e), extractMetrics(transcript)));\n\n conn.connect({\n host,\n port,\n username: sshUser,\n password: sshPass,\n tryKeyboard: false,\n readyTimeout: 10_000,\n });\n });\n}\n\n// === 執行 ===\nconst result = await runPttSsh();\n\nreturn [{\n json: {\n status: result.status,\n message: result.message,\n logs,\n metrics: result.metrics || { lastLoginIp: '', hasNewMail: false },\n }\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
448,
0
],
"id": "485343d5-c014-444f-a920-e2b557a1571c",
"name": "PTT Auto Login"
}
],
"connections": {
"Schedule Trigger": {
"main": [
[
{
"node": "ptt 帳號密碼",
"type": "main",
"index": 0
}
]
]
},
"ptt 帳號密碼": {
"main": [
[
{
"node": "PTT Auto Login",
"type": "main",
"index": 0
}
]
]
},
"PTT Auto Login": {
"main": [
[
{
"node": "Send a text message",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "5991fee1550ad1a0849eb08cf2b1577db37fc306c8b135d2d09a0fab612b3ea2"
}
}
貼好以後你就會看見如下畫面

首先雙擊 1 的 Schedule Trigger,可以設定每日幾點幾分自動執行
再來雙擊 2 裡面設定你要登入的 PTT 帳號與密碼
最後設定一下 Telegram node 的機器人與你要傳送到誰的 Telegram ID
之後 n8n 就會每天在你設定的時間自動 PTT 登入並且登出了,並且還會順便幫你檢查有無站內信,實際收到的 Telegram 通知如下圖

如此就完成了,n8n 版本的 PTT Auto Login 囉!
現在有 AI 要完成這類簡單程式碼真是輕鬆許多,就算你是程式小白,搭配 n8n 與 AI 就可以做到許多好玩的事情~
而且現在 n8n 也支援原生 Python code node 環境,所以你偏好跑 Python 也是依樣畫葫蘆就能搞定
最後如果你要「養」多個 PTT 帳號的話,那就直接 Duplicate 多個 n8n workflow 出來即可 🖖
