Abend

從 Kobo 每日 99 到嘟文的 n8n 血淚

初次嘗試

大概從 2022 年底開始使用 n8n,當時因為 SNS 重心開始移往 Mastodon,所以想把我的 Instagram 同步到 Mastodon。因緣際會找到了 n8n 這個工具,簡單研究了一下後在 DigitalOcean 上自架了 n8n 開始玩。

因為也是 Kobo 電子書的長期用戶,又懶得每天去看今天的 99 元特價書是哪一本,所以一直想寫個抓取今天的 Kobo 99 訊息自動貼到 Mastodon 上的自動化流程。那時候的想法是透過 HTTP Request Node 抓取 Kobo 首頁,然後解析每日 99 的訊息區塊,找出我要的資訊後貼到 Mastodon 即可,聽起來很簡單對不對?但是幾年前初次嘗試的時候,n8n 抓 Kobo 網頁會一直拿到 403 error,那時候以為 Kobo 會擋國外 IP,我又找不到合適的 Proxy 可以使用,就這樣被我擱著了。

好像有戲?

上週在 Kobo 電子書的 Telegram 討論群跟其他人聊到,發現有些人在國外也可以開啟 Kobo 頁面購書。那時候在想該不會其實是檔部分 IP?所以我先在多倫多開了一個全新的 droplet,用 curl 可以正確抓取 kobo 首頁;於是建了一個既有 droplet 的 snapshoot,並增加了多倫多區域,然後在多倫多用 snapshoot 開了新的 droplet。不過等到 droplet 開起來,嘗試 curl kobo,卻又是得到 403 的結果…

後來也嘗試了幾個不同區域,終於在法國 try 到一個 curl 可以拿到 200 的 IP,因為是空的 droplet,加上我剛好想換 n8n domain,於是重新安裝了一次 n8n。

建立 droplet

參考的是 n8n 官方在 DigitalOcean 上託管 n8n的文件。不過因為文件中建立 droplet 是使用 DigitalOcean Marketplace 中的 Docker image,而這個 image 的 OS 還是 Ubuntu 22.04 LTS,我想改用 Ubuntu 24.04 LTS,所以稍微調整了一下,先開了一個 Ubuntu 24.04 的 droplet。

然後使用 docker 官方提供的腳本安裝 docker 後,再依照 n8n 文件完成後續動作。

curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh

至於舊的 n8n 上的 Credentials 以及 Workflows,則是以 CLI command 先匯出檔案後,再到新的 droplet 中匯入,雖然 Credentials 還是需要重新設定一次,不過總比手動建立來得好。

#匯出
n8n export:credentials --all
n8n export:workflow --all
#匯入
n8n import:credentials --input=file.json
n8n import:workflow --input=file.json

理想很豐滿,現實…

Kobo 的首頁前陣子剛好改版,無聊看了一下,發現今日 99 可以直接用 API 抓,不需要自己爬網頁了。Endpoing 是 https://www.kobo.com/api/kobo-ui/store/api/v1/featurelistproducts,POST 底下參數就可以拿到今日 99 的資訊。

{
    "country": "tw",
    "language": "zh",
    "featureGroupName": "Home.Spotlight.1"
}

用 curl 的話,指令大概是這樣:

curl -H 'Referer: https://www.kobo.com/tw/zh' \
    -H 'Accept-Language: zh-TW,zh;q=0.9,en-US;q=0.8,en;q=0.7' \
    -H 'Content-Type: application/json' \
    -A "Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0" \
    -d '{ "country": "tw", "language": "zh", "featureGroupName": "Home.Spotlight.1"}' \
    https://www.kobo.com/api/kobo-ui/store/api/v1/featurelistproducts

在 n8n 先建立簡單的流程,用 HTTP Request node 匯入上述的 curl 後執行,結果又是 403?!想在 image 內 curl 看看,結果因為 docker image 用的是 Alpine Linux,原本並沒有安裝 curl,所以我先另外開了一個 Dockerfile

FROM docker.n8n.io/n8nio/n8n
USER root
RUN apk --update add curl
USER node

接著修改 docker-compose.yaml

#把
n8n:
    image: docker.n8n.io/n8nio/n8n
#改成
n8n:
    build: .
    image: my-n8n-with-curl

然後在 image 裡用 curl 指令再嘗試一次,果然還是 403。也有用 curl -v 印出結果來看

看起來除了 CA 有一點不一樣之外,其他基本上是一模一樣。不太確定 host 跟 image 用同樣指令卻有不同結果的原因為何,總之這條路暫時看起來是死路。

後來想到,另一種解法是,透過別的服務取得內容,再打 n8n webhook 完成後續動作。先後嘗試了 Google Apps Script、Make.com 以及 Zapier,也都是拿到 403 的結果…這條路也是不通。

路不轉人轉

山不轉路轉,路不轉人轉;既然如此,那我在本機跑總行了吧?先請 Gemini 幫我寫個 script,抓取 featurelistproducts 內容後,送到 n8n webhook,再去拆解資訊下載圖檔等等。然後又發現下載圖檔也是 403,所以又請 Gemini 修改。script 最終長下面這樣,簡單說就是取得 kobo json,並下載書籍圖檔,最後再把這兩樣資訊 post 給 n8n 的 webhook。

#!/bin/bash

# 定義第一個 API 的 URL 和 JSON 資料
KOBO_API_URL="https://www.kobo.com/api/kobo-ui/store/api/v1/featurelistproducts"
KOBO_PAYLOAD='{ "country": "tw", "language": "zh", "featureGroupName": "Home.Spotlight.1"}'

# 定義第二個 API 的 URL (n8n webhook)
N8N_WEBHOOK_URL="{n8n_webhook}"

# 設定 Kobo API 的 HTTP Headers
KOBO_HEADERS=(
  "-H" "Referer: https://www.kobo.com/tw/zh"
  "-H" "Accept-Language: zh-TW,zh;q=0.9,en-US;q=0.8,en;q=0.7"
  "-H" "Content-Type: application/json"
  "-H" "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0"
)

# 使用 curl 發送 POST 請求到 Kobo API 並將回應儲存到臨時檔案,同時獲取 HTTP 狀態碼
KOBO_RESPONSE_FILE=$(mktemp)
kobo_status=$(curl -s -o "$KOBO_RESPONSE_FILE" "${KOBO_HEADERS[@]}" -w "%{http_code}" -X POST -d "$KOBO_PAYLOAD" "$KOBO_API_URL")

# 從臨時檔案讀取回應內容
kobo_response=$(cat "$KOBO_RESPONSE_FILE")

# 清理臨時檔案
rm "$KOBO_RESPONSE_FILE"

# 檢查第一個請求的 HTTP 狀態碼是否為 200
if [ "$kobo_status" -eq 200 ]; then
  echo "成功從 Kobo API 取得資料 (HTTP 狀態碼: $kobo_status)。"
  echo "回應內容:"
  echo "$kobo_response"

  # 提取圖片網址並下載圖片
  image_root_url=$(echo "$kobo_response" | jq -r '.data.products[0].image.imageRootUrl')
  image_id=$(echo "$kobo_response" | jq -r '.data.products[0].image.imageId')
  image_file=$(echo "$kobo_response" | jq -r '.data.products[0].image.imageFile')
  image_url="https:${image_root_url}${image_id}/${image_file}"
  image_filename="kobo_image.jpg"
  /usr/bin/curl -s -o "$image_filename" "$image_url"
  image_download_status=$?

  # 判斷圖檔是否下載成功
  if [ "$image_download_status" -eq 0 ]; then
    echo "圖檔下載成功,儲存為 $image_filename"

    # 使用 curl 以 multipart/form-data 的形式上傳圖片和 JSON 資料
    n8n_response=$(curl -s -X POST \
      -F "image=@$image_filename" \
      -F "kobo_data=$kobo_response" \
      "$N8N_WEBHOOK_URL")

    # 檢查第二個請求是否成功
    if [ $? -eq 0 ]; then
      echo "成功將 Kobo API 的回應和圖片以 multipart/form-data 發送到 n8n webhook"
      echo "n8n 回應內容:"
      echo "$n8n_response"

      # 刪除本地圖片檔案
      rm "$image_filename"

    else
      echo "發送資料到 n8n webhook 失敗。"
      # 不刪除本地圖片,方便檢查錯誤
    fi

  else
    echo "圖檔下載失敗,錯誤代碼: $image_download_status"
    exit 1 # 圖檔下載失敗,終止腳本
  fi

else
  echo "從 Kobo API 取得資料失敗 (HTTP 狀態碼: $kobo_status)。"
  exit 1 # 從 Kobo API 取得資料失敗,終止腳本
fi

exit 0

n8n workflow

至於 n8n 上的 workflow,大概是長這樣。當然你要記得替換 mastodon instance url,並選擇你的認證方式。

{
  "name": "Kobo 99",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "",
        "options": {}
      },
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        0
      ],
      "name": "Webhook",
      "webhookId": "",
      "notesInFlow": false
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://{mastodon}/api/v1/statuses",
        "authentication": "genericCredentialType",
        "sendHeaders": true,
        "sendBody": true,
        "contentType": "multipart-form-data",
        "bodyParameters": {
          "parameters": [
            {
              "name": "status",
              "value": "=[Kobo {{ $json.caption }}]\n\n📘 {{ $json.title }}\n👤 {{ $json.authors }}\n🗓 {{ (new Date($json.publishDate)).format(\"yyyy-MM-dd\") }}\n🔗 {{ $json.page }}\n---\n{{ $json.description.length() > 300 ? $json.description.substring(0,300)+\"...\" : $json.description }}\n\n#電子書 #Kobo #Kobo今日99"
            },
            {
              "name": "media_ids[]",
              "value": "={{ $json.id }}"
            },
            {
              "name": "visibility",
              "value": "public"
            }
          ]
        },
        "options": {}
      },
      "name": "Publish New Toot",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 3,
      "position": [
        880,
        0
      ]
    },
    {
      "parameters": {
        "mode": "combine",
        "combineBy": "combineAll",
        "options": {}
      },
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3.1,
      "position": [
        660,
        0
      ],
      "name": "Merge"
    },
    {
      "parameters": {
        "authentication": "genericCredentialType",
        "requestMethod": "POST",
        "url": "https://{mastodon}/api/v1/media",
        "jsonParameters": true,
        "options": {
          "bodyContentType": "multipart-form-data"
        },
        "sendBinaryData": true,
        "binaryPropertyName": "=image"
      },
      "name": "Upload image to mastodon",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 2,
      "position": [
        220,
        -200
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "name": "body",
              "value": "={{ $json.body.kobo_data }}",
              "type": "object"
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        220,
        0
      ],
      "name": "Get json"
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "name": "caption",
              "value": "={{ $json.body.data.title }}",
              "type": "string"
            },
            {
              "name": "title",
              "value": "={{ $json.body.data.products[0].title }}",
              "type": "string"
            },
            {
              "name": "page",
              "value": "=https://kobo.com{{ $json.body.data.products[0].itemPageUrl }}",
              "type": "string"
            },
            {
              "name": "rating",
              "value": "={{ $json.body.data.products[0].rating.starRating }}",
              "type": "number"
            },
            {
              "name": "authors",
              "value": "={{ $json.body.data.products[0].authors }}",
              "type": "array"
            },
            {
              "name": "publishDate",
              "value": "={{ $json.body.data.products[0].publicationDate }}",
              "type": "string"
            },
            {
              "name": "description",
              "value": "={{ $json.body.data.products[0].description }}",
              "type": "string"
            }
          ]
        }
      },
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        440,
        0
      ],
      "name": "Parse json"
    }
  ],
  "connections": {
    "Webhook": {
      "main": [
        [
          {
            "node": "Get json",
            "type": "main",
            "index": 0
          },
          {
            "node": "Upload image to mastodon",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge": {
      "main": [
        [
          {
            "node": "Publish New Toot",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload image to mastodon": {
      "main": [
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get json": {
      "main": [
        [
          {
            "node": "Parse json",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse json": {
      "main": [
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 1
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "saveDataSuccessExecution": "none",
    "callerPolicy": "workflowsFromSameOwner"
  }
}

手動執行 script 後,等流程跑完就會看到 toot 出現在 mastodon 上了~ 發 toot 了

最後一哩路

當然我也不可能每次都自己執行 script,不過因為我的 mac 平常沒事會蓋著螢幕休眠,我該怎麼確保在休眠且未登入的狀態下也會自己執行排程呢?再次請出 Gemini,他給我的答案是 pmset + launchd。

我打算每天早上 09:00 執行這個排程,我需要先讓機器在這段時間是醒著的。透過這個指令,讓機器每天 08:55 醒來。因為 webhook 是設定馬上回應,不用等到結果跑完,為了避免浪費電,再讓機器在早上 09:05 進入休眠,你可以依照你的需求自行調整。

sudo pmset repeat wake MTWHFSU 08:55:00 sleep MTWHFSU 09:05:00

要確保設定正確,可以用 sched 檢查你的設定是否正確顯示。

$ sudo pmset -g sched
Repeating power events:
  wake at 8:55AM Some days
  sleep at 9:05AM Some days

接著需要建立 plist 檔案,讓 launchd 知道該怎麼執行任務,使用文字編輯器建立名為 com.yourname.myscript.plist 的檔案。因為我想讓這個排程可以在未登入的狀態也會執行,所以需要把 plist 放置在 /Library/LaunchDaemons/ 目錄中(需要管理者權限)。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.yourname.myscript</string>
    <key>ProgramArguments</key>
    <array>
        <string>/bin/bash</string>
        <string>/usr/local/bin/myscript.sh</string>
    </array>
    <key>StartCalendarInterval</key>
    <dict>
        <key>Hour</key>
        <integer>9</integer>
        <key>Minute</key>
        <integer>0</integer>
    </dict>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <false/>
    <key>StandardOutPath</key>
    <string>/tmp/myscript.log</string>
    <key>StandardErrorPath</key>
    <string>/tmp/myscript_error.log</string>
</dict>
</plist>

我把 .sh 放在 /usr/local/bin 的原因是 launchd 會以 root 執行,最好避免將 script 放在使用者目錄內,免得因為權限問題而無法正常執行。接著需要載入這個 plist。

sudo launchctl load /Library/LaunchDaemons/com.yourname.myscript.plist

執行的時候,發現 script 在 log 裡寫了 圖檔下載失敗,錯誤代碼: 56。錯誤代碼 56 在 curl 中通常是表示 網路接收資料時發生錯誤,連線中斷或重置。這個原因是因為 launchd 執行時工作目錄是在根目錄,而影響了檔案的讀寫。所以回頭把 script 的 image_filename="kobo_image.jpg 改為 image_filename="/usr/local/bin/kobo_image.jpg"。這樣就可以解決錯誤代碼 56 的問題了。

柳暗花明又一村

結果下午才剛打完前面那一大段,晚上無聊亂試就發現 docker 裡面 curl 加上 --no-alpn 就拿到 HTTP status 200 了!!所以我又改了一個版本,單純只用 n8n,不過開始之前我們要先把前面的 pmset 以及 launchd 給關掉。

## pmset
sudo pmset repeat cancel

## launchd
sudo launchctl unload /Library/LaunchDaemons/com.yourname.myscript.plist

n8n new workflow

新的 workflow 看起來就像這樣。Publish Toot node 之前的 Merge 節點是因為如果前面直接接 Upload Image node,不知道為什麼 Publish Toot 就抓不到 Parse Json 的 output。所以我從 Parse Json 多拉了一條線到 Merge,這樣才能正確抓到資訊。

{
  "nodes": [
    {
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        0
      ],
      "name": "Schedule Trigger"
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "name": "caption",
              "value": "=[Kobo {{ $json.kobo.data.title }}]",
              "type": "string"
            },
            {
              "name": "title",
              "value": "={{ $json.kobo.data.products[0].title }}",
              "type": "string"
            },
            {
              "name": "page",
              "value": "=https://kobo.com{{ $json.kobo.data.products[0].itemPageUrl }}",
              "type": "string"
            },
            {
              "name": "rating",
              "value": "={{ $json.kobo.data.products[0].rating.starRating }}",
              "type": "number"
            },
            {
              "name": "authors",
              "value": "={{ $json.kobo.data.products[0].authors }}",
              "type": "array"
            },
            {
              "name": "publishDate",
              "value": "={{ $json.kobo.data.products[0].publicationDate }}",
              "type": "string"
            },
            {
              "name": "image",
              "value": "=https:{{ $json.kobo.data.products[0].image.imageRootUrl }}{{ $json.kobo.data.products[0].image.imageId }}/{{ $json.kobo.data.products[0].image.imageFile }}",
              "type": "string"
            },
            {
              "id": "e96d6cb7-b5eb-44cd-9682-45831bb55efa",
              "name": "description",
              "value": "={{ $json.kobo.data.products[0].description }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        660,
        0
      ],
      "name": "Parse json"
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "name": "kobo",
              "value": "={{ $json.stdout }}",
              "type": "object"
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        440,
        0
      ],
      "name": "Convert String to Json"
    },
    {
      "parameters": {
        "command": "curl -H 'Referer: https://www.kobo.com/tw/zh' -H 'Accept-Language: zh-TW,zh;q=0.9,en-US;q=0.8,en;q=0.7' -H 'Content-Type: application/json' -A \"Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0\" --no-alpn -o /tmp/image.jpg {{ $json.image }}"
      },
      "type": "n8n-nodes-base.executeCommand",
      "typeVersion": 1,
      "position": [
        880,
        0
      ],
      "name": "Download Image"
    },
    {
      "parameters": {
        "command": "curl -H 'Referer: https://www.kobo.com/tw/zh' -H 'Accept-Language: zh-TW,zh;q=0.9,en-US;q=0.8,en;q=0.7' -H 'Content-Type: application/json' -A \"Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0\" --no-alpn -d '{ \"country\": \"tw\", \"language\": \"zh\", \"featureGroupName\": \"Home.Spotlight.1\"}' https://www.kobo.com/api/kobo-ui/store/api/v1/featurelistproducts"
      },
      "type": "n8n-nodes-base.executeCommand",
      "typeVersion": 1,
      "position": [
        220,
        0
      ],
      "name": "Get Json"
    },
    {
      "parameters": {
        "fileSelector": "/tmp/image.jpg",
        "options": {}
      },
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1,
      "position": [
        1100,
        0
      ],
      "name": "Read Binary"
    },
    {
      "parameters": {
        "authentication": "genericCredentialType",
        "requestMethod": "POST",
        "url": "https://abend.social/api/v1/media",
        "jsonParameters": true,
        "options": {
          "bodyContentType": "multipart-form-data"
        },
        "sendBinaryData": true,
        "binaryPropertyName": "=data"
      },
      "name": "Upload image to mastodon",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 2,
      "position": [
        1320,
        0
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://abend.social/api/v1/statuses",
        "authentication": "genericCredentialType",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer SBv4vQr1KSJwdhq-oa295kTItdBwnt1KUXpnf2GhWuM"
            }
          ]
        },
        "sendBody": true,
        "contentType": "multipart-form-data",
        "bodyParameters": {
          "parameters": [
            {
              "name": "status",
              "value": "={{ $json.caption }}\n\n📘 {{ $json.title }}\n👤 {{ $json.authors }}\n🗓 {{ (new Date($json.publishDate)).format(\"yyyy-MM-dd\") }}\n🔗 {{ $json.page }}\n---\n{{ $json.description.length() > 300 ? $json.description.substring(0,300)+\"...\" : $json.description }}\n\n#電子書 #Kobo #Kobo今日99"
            },
            {
              "name": "media_ids[]",
              "value": "={{ $json.id }}"
            },
            {
              "name": "visibility",
              "value": "direct"
            }
          ]
        },
        "options": {}
      },
      "name": "Publish New Toot",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 3,
      "position": [
        1760,
        0
      ]
    },
    {
      "parameters": {
        "mode": "combine",
        "combineBy": "combineAll",
        "options": {}
      },
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3.1,
      "position": [
        1540,
        0
      ],
      "name": "Merge"
    }
  ],
  "connections": {
    "Schedule Trigger": {
      "main": [
        [
          {
            "node": "Get Json",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse json": {
      "main": [
        [
          {
            "node": "Download Image",
            "type": "main",
            "index": 0
          },
          {
            "node": "Merge",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Convert String to Json": {
      "main": [
        [
          {
            "node": "Parse json",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Download Image": {
      "main": [
        [
          {
            "node": "Read Binary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Json": {
      "main": [
        [
          {
            "node": "Convert String to Json",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Binary": {
      "main": [
        [
          {
            "node": "Upload image to mastodon",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload image to mastodon": {
      "main": [
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge": {
      "main": [
        [
          {
            "node": "Publish New Toot",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}

打完收工

最終的成果在 @[email protected] 可以看到,也歡迎有使用 Mastodon 的朋友追蹤。目前除了 kobo 99 的訊息外,也會同步東立出版社的新電子書資訊喔~