忘記在哪邊看到有人問,有沒有 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 出來即可 🖖