[實作筆記] n8n AI 週報踩坑記:一條接錯的線,與找免費生圖來源的旅程

前情提要

我有個 n8n workflow,每週自動抓 AI 新聞 → Gemini 生週報 → 發一篇 IG,順便生一張配圖、也產一篇 Blog 週報開 PR。

跑了一陣子,兩個毛病:

  1. 每次發兩篇 IG,一篇正常、一篇是空的。
  2. 配圖每週都是動物,跟當週主題八竿子打不著。

本來以為是兩個獨立問題,結果查下去——根因是同一條接錯的線。這篇記錄整個排查過程,還有後面為了「免費生圖」繞的一大圈。


流程長怎樣

出問題時的原始架構:

1
2
3
4
Schedule → RSS×4 → Filter → Edit Fields → Aggregate
├─ 文字生成(Gemini) → IG 準備發文 → IG 發佈
├─ 生成圖片 Prompt(Gemini) → IG 準備發文 ↑(兩條線都接這)
└─ Blog 生成(Gemini) → GitHub 建 branch → commit → 開 PR

配圖用 image.pollinations.ai 生成,圖片 URL 直接餵給 IG。

問題一:一次發兩篇 IG

根因

讀 workflow JSON 畫連線,發現 IG 準備發文 這個節點的同一個輸入埠(input 0)被兩條線餵:文字生成 和 生成圖片 Prompt 都接進去。

n8n(executionOrder v1)在「同一輸入埠收到多條來源連線」時,每條來源各觸發一次該節點 → IG 準備發文 跑兩次 → 發兩篇。這是 n8n 經典陷阱:該用 Merge 合併分支的地方,直接把兩條線併到同一個 input。

順便還抓到一個 bug:IG 那個 httpRequest 的 body 參數名是 " image_url",開頭多一個空格,IG 收到的 key 變成 image_url 被忽略 → 圖片沒帶進去 → 空貼文。

修法

把「並聯」改成「串聯」:

1
Aggregate → 文字生成 → 生成圖片 Prompt → IG 準備發文
  • IG 準備發文 只剩一條 input → 跑一次 → 發一篇
  • 順手把 " image_url" 的空格拿掉

問題二:圖片每週都動物

根因(其實還是那條線)

Pollinations 要的是「視覺場景描述」(英文、具體畫面),餵新聞摘要長文它抓不到重點 → 退化成安全牌,而動物是文字生圖最常見的 fallback。

但更前面的根因是:workflow 裡其實已經有一個「生成圖片 Prompt」的 Gemini 節點,prompt 也寫得不錯(挑最有畫面感的一則、用英文描述場景)。問題是它接錯位置——從 Aggregate 進來,而 Aggregate 輸出的是 { data: [...] },沒有 .text。節點模板寫 {{ $json.text }} → 拿到 undefined → 等於餵空內容 → 圖當然爛。

所以同一個接線錯誤:既讓 生成圖片 Prompt 拿不到週報內容,又跟 文字生成 併線造成重複發文。

修法

問題一的串接修法,順便就解了問題二——生成圖片 Prompt 接在 文字生成 之後,$json.text 真的拿得到週報全文了。再把它的 prompt 強化(固定畫風關鍵字 digital illustration, vibrant colors, no text, no logo、排除人名品牌、加 SFW 防護)。

教訓:兩個看起來無關的 bug,根因可能是同一個。先畫連線圖再動手。

插曲一:Blog 分支同天重跑撞名(422)

修完去重跑驗證,Blog 那條噴 422 Reference already exists。

根因:branch 名固定用 weekly/{{yyyyMMdd}},同一天手動重跑就撞到自己上次建的 branch。正常每週排程日期不同不會撞,但測試時會。

修法:

  • branch 名改成 weekly/{{yyyyMMdd-HHmmss}},同天重跑不撞
  • 後面 commit / 開 PR 的節點,branch 改成讀「建 branch」實際回傳的 ref(.ref.replace('refs/heads/','')),保證三個節點用同一個名字,不會各自用 $now 重算而漂移

插曲二:4 個 RSS 來源讓整條鏈跑多次

上線後跑了一次排程,GitHub 吐出 PR #65 和 PR #66——同一週跑出兩個週報 PR,IG 也連帶發了兩篇。

根因跟問題一其實是同一個模式:4 個 RSS 來源(Theverge、OpenAI、Google AI、Hackernews)各自直接連到 Filter 的 input 0,n8n 把每條來源視為獨立執行分支,Filter → Edit Fields → Aggregate 以下的整條鏈跑了 4 次,GitHub Create PR 就出現了 4 次(有的因 branch 撞名被 422 擋掉,但 IG 發文的那幾次成功了)。

修法:在 4 個 RSS Read 之後加一個 Merge RSS 節點(append mode),把 4 條分支匯聚成一條再進 Filter,確保下游只跑一次。

1
RSS×4 → Merge RSS(append)→ Filter → Edit Fields → Aggregate → ...

Merge 節點已加入,等下次排程(每週三)實際跑一次驗證。

大冒險:到底哪裡能免費生圖?

IG 兩個原始 bug 修好上線了,但配圖來源出事——這段繞最久。

關鍵限制:IG Graph API 是用「它自己的伺服器」去抓 image_url。所以不管我這邊怎麼生圖,只要那個 URL 被擋,IG 就抓不到。

來源 結果
Pollinations 匿名 402 Payment Required。它改政策了:匿名並發限 1,超過就回 JSON 錯誤(還夾帶 x402 加密貨幣付款,要付 USDC 繞過)
Pollinations 註冊 token 舊的 auth.pollinations.ai 網域已下線(DNS 查無),現行入口是付費 Console
Gemini 圖片(我現有的 key) 429,free tier limit: 0。文字免費、圖片要開帳單付費層
Replicate flux 沒有正式免費額度,試用後要綁信用卡(約 $0.003/張)

繞一圈結論:「真正免費、不綁卡、又能讓第三方抓圖」的組合幾乎絕跡。

最終解:Cloudflare Workers AI + GitHub 托管 + 一圖共用

先講結論:用 Cloudflare Workers AI 的 flux-1-schnell 生圖(免費、不綁卡),把圖 commit 進 Hexo repo,文章嵌入它、IG 也用同一張的 raw URL。

為什麼是 Cloudflare Workers AI

  • 免費層每天 10,000 neurons,不用綁信用卡,只要免費 Cloudflare 帳號
  • 一週一張圖,額度永遠用不完 → 實質 $0
  • 實測 OK:回合法 1024×1024 JPEG(base64)

為什麼一定要自己托管圖(這步躲不掉)

IG 發「照片」貼文時,是給它一個公開 URL、它自己伺服器去抓——不能直接上傳圖片 bytes(只有 Reels/影片能 binary 上傳)。而 flux 回的是 bytes 不是 URL。所以中間一定要有個「把 bytes 變成公開 URL」的托管處。

評估過 R2 / GCS / Imgur / 備份 repo,最後選 Hexo 的 public repo:

  • GitHub 憑證早就接在 n8n 裡、repo 又是 public → 零新帳號、零成本、IG 抓 raw.githubusercontent.com 很穩
  • 備份 repo 不能用(private + 內含明文 token,不能公開)
  • 巧合:flux 回 base64、GitHub Contents API 剛好也吃 base64,中間免轉檔

一圖共用(這次的重點設計)

原本 Blog 與 IG 是兩條平行分支。為了「文章嵌圖 + IG 用同一張」,把它們串成一條:生一次圖 → commit 到該週分支 → 文章 md 嵌入 ![](/images/...) → IG 用同一張的 raw URL。一次生圖、零重複、圖也進了 blog。

1
2
3
Aggregate → Blog生成 → 文字生成 → 生成圖片Prompt → CF生圖(flux,base64)
→ 取main SHA → 建分支(weekly/<日期-時間>) → 上傳圖到GitHub
→ Blog建檔(嵌圖) → Blog Post → 開PR → IG準備發文(同一張raw URL) → Wait → IG發佈

代價:兩條從「獨立」變「耦合」(中間任一步失敗,後面不跑)。換來一圖共用。

接的時候又踩的坑

  • n8n Header Auth 憑證填錯欄位 → 401:Name 要填 Authorization(不是憑證用途名),Value 要 Bearer <token>(不是只貼 token)。
  • CLI import vs UI 編輯互相蓋:用 CLI 匯入改的是 DB,但若瀏覽器還開著舊畫布,一存就把匯入蓋掉。改之前先關掉所有編輯分頁、改完再 refresh。
  • Gemini 503 過載:gemini-flash-latest 遇大尖峰整個 workflow 掛。加 retryOnFail(4 次/5 秒)擋小尖峰;大範圍過載只能換模型(換成 gemini-flash-lite-latest)。已記成 backlog,想做「多模型自動 fallback」。
  • Blog CI 競態:repo 的 ci.yml 在 pull_request 也跑,會在 PR 的 detached HEAD 上把 ncu -u 結果推回 main → 必然 ! [rejected] (fetch first),每開一個週報 PR 就失敗一次。修法:拿掉 pull_request 觸發 + 加 concurrency。

小結

  • 兩個看似獨立的 bug(發兩篇、圖都動物),根因是同一條接錯的線。排查 n8n 先畫連線圖。
  • n8n「同一輸入埠接多條線會重複觸發」,要合併分支用 Merge、別直接併線。
  • 跨節點共用的值(branch 名)算一次、其他節點引用,別各自 $now 重算。
  • 免費服務會退化:Pollinations 從「免簽免 key」變 402 + 加密付款;Gemini 圖片免費層 limit=0。架構別把命脈押在單一免費服務上。
  • IG 發照片一定要公開圖床(它自己抓 URL),逼你先想清楚托管在哪。
  • 動手前先驗證(列得出模型 ≠ 生得出圖、free tier 可能 limit=0),而且會花錢/動到別人系統的事先問過再做。

(Merge RSS 節點已加,等下次排程實際跑過驗證。之後補上實際成品圖。)

參考

(fin)

[AI生成] 20260605 科技周報

AI 週報配圖

本周要點

其他訊息

(fin)

[實作筆記] 盤點與清理自訂網域的 Email DNS 殘留(用郵件 header 當偵探)

前情提要

這幾天整理 Cloudflare 的 DNS,看到幾筆我沒有記憶也不理解的記錄:

1
send.marsen.me   MX   feedback-smtp.ap-northeast-1.amazonses.com

Amazon SES?我什麼時候設過?還在用嗎?敢不敢刪?

不確定就不要亂刪 DNS——刪錯一筆,輕則信進垃圾桶,重則收不到信。所以乾脆把整個 marsen.me 上的 email 服務盤點一次,搞清楚每一筆 DNS 到底在幹嘛。

第一步,列出 DNS Record

把 DNS 裡跟 email 有關的記錄抓出來,對應到服務:

| 服務 | 相關 DNS 記錄 |
| —— |————–|
| Cloudflare Email Routing | marsen.me MX(route1-3.mx.cloudflare.net) |
| Brevo(原 Sendinblue) | brevo1/2._domainkey CNAME |
| Resend | resend._domainkey TXT |
| Amazon SES | send.marsen.me MX + SPF |

四套服務、一堆 DKIM/SPF/MX,光看 DNS 根本分不出哪些還在使用,用途為何?

第二步,理解 DNS Record

可以參考文章最末的參考資料,這裡簡單說明 MX 與 SPF,以後有機會再進一步說明

MX: 告訴別人「寄到 @你的網域 的信,送去哪台伺服器,

你家的收件信箱地址,別人寄東西來才送得到。

SPF / DKIM = 你寄信時的防偽印章和授權書。

證明「這封信真的是你(或你授權的服務)寄的」,防止被當垃圾或冒名

所以一個純發信的服務(像 Brevo、Resend),只需要 DKIM 印章,根本不需要 MX。這個觀念是後面判讀的基礎。

補充:SPF 和 DKIM 的內容本體都是 TXT,但 DKIM 有兩種發布方式——
一種像 Resend 是把公鑰直接寫成 TXT 給你貼;
另一種像 Brevo 則是給你一筆 CNAME,把 brevoX._domainkey 委派指向它自家的 DNS,公鑰放在它那邊,方便它自己輪替金鑰。
所以上面表格裡 Brevo 是 CNAME、Resend 是 TXT,但做的是同一件事。

寄收信測試

我們可以查看郵件 header——它記錄了一封信實際走過的每一站。

方法很簡單:寄一封測試信給自己,打開「顯示原始郵件」看 header。

我用 [email protected] 寄一封信給自已

1
2
3
4
5
Message-Id: <[email protected]>
Received: from hb.d.sender-sib.com (...)
DKIM-Signature: ... d=marsen.me s=brevo2 ... → PASS
Feedback-ID: ...:Sendinblue
Return-Path: <[email protected]>

sendinblue、sender-sib(SendInBlue 的縮寫)、s=brevo2 的 DKIM 簽章——這封信百分之百走 Brevo 發出。

s=brevo2 還正好對應到我 DNS 裡的 brevo2._domainkey。Brevo 確認在用。

同一封信,還證明了收信端

更妙的是,這封我寄給 [email protected] 的信,被轉發回我 Gmail,header 把收信路徑也記下來了:

1
2
3
4
Received: by cloudflare-email.net       → Cloudflare Email Routing 收到
X-Forwarded-To: [email protected]
X-Forwarded-For: [email protected] [email protected]
Delivered-To: [email protected]

一封信同時驗證了兩端:Brevo 發信 → Cloudflare Email Routing 收信並轉發 → 進 Gmail。

那「收信」到底是怎麼運作的?

順著 header 就看懂了,我的收信是 Cloudflare Email Routing:

1
2
3
4
5
別人寄到 [email protected]
→ marsen.me 的 MX 指向 Cloudflare(route1-3.mx.cloudflare.net)
→ Cloudflare Email Routing 收到
→ 依轉發規則(Routes)轉寄
→ 我的真實 Gmail

重點:Cloudflare Email Routing 是轉發,不是信箱。

它不存信,收到就立刻轉寄到你真正的 Gmail。所以 @marsen.me 沒有獨立的信箱空間,所有信最終都進同一個 Gmail。

整套自訂網域信箱長這樣:

1
2
3
            ┌─ 收信 → Cloudflare Email Routing(MX)→ 轉發 ┐
@marsen.me ─┤ ├→ Gmail
└─ 寄信 ← Brevo SMTP relay ← Gmail「以 admin@ 寄」┘

用免費服務,把 Gmail 變成 @marsen.me 的網域信箱。

這就是我免費達成私有 domain 信件寄收的作法,但是要注意 Brevo 有每日 300 封信的使用上限

盤點結果

三重交叉查證(程式碼 grep、n8n 備份、郵件 header)之後:

| 服務 | 狀態 | 證據 |
| ——|——|——|
| Cloudflare Email Routing | ✅ 在用 | header 轉發路徑 |
| Brevo | ✅ 在用 | header 的 sendinblue / brevo2 簽章 |
| Resend | ✅ 在用 | 電商專案發訂單信,程式碼有引用 |
| Amazon SES | ❌ 殘留 | 任何地方都找不到使用,連 SES 驗證記錄都沒有 |

SES 是當初設定網域信箱時評估過的備選方案(按量計費、便宜但設定複雜),最後選了 Brevo,

但 AWS 用的 send.marsen.me 的 MAIL FROM 記錄留下來沒清。

為什麼在用的 Brevo 沒有 MX,沒用的 SES 反而有?

這題剛好回扣前面「收信才用 MX」的觀念,但有個例外:

  • Brevo:純發信,靠 DKIM + SPF 驗證授權,不需要 MX。所以它在用,卻沒有 MX。
  • SES:那個 send.marsen.me 的 MX 不是收一般信,而是 SES 的「custom MAIL FROM」機制要求的,用來接收退信/投訴回報(bounce/complaint)。

諷刺的是:沒在用的 SES 因為特殊機制留了 MX,在用的 Brevo 因為純發信反而沒有 MX。

光看「有沒有 MX」完全判斷不出哪個在用。

安全清理

確認 SES 是殘留後,要刪的只有兩筆(name 都是 send.marsen.me)。

刪之前先把完整內容記下來——理論上它一定沒用了,但留個底,萬一哪天要恢復可以照著重建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# ── 被刪除的 SES 殘留記錄(刪除日:2026-06-04,留供日後恢復)──

# 第一筆:SES custom MAIL FROM 的 bounce 接收
Type = MX
Name = send.marsen.me
Content = feedback-smtp.ap-northeast-1.amazonses.com
Priority = 10
Proxy = DNS only
TTL = 1 hour

# 第二筆:SES 的 SPF
Type = TXT
Name = send.marsen.me
Content = "v=spf1 include:amazonses.com ~all"
Proxy = DNS only
TTL = 1 hour

註:若真要復原 Amazon SES 寄信,光補這兩筆 DNS 不夠,還得回 AWS Console(ap-northeast-1 東京)重新驗證 identity 與 custom MAIL FROM。這兩筆只是當時設定留下的 DNS 半成品。

小結

盤點 email DNS 殘留,兩個心法:

  1. 分清收信和發信——MX 是收信,SPF/DKIM 是發信驗證。看到一筆記錄先問它管的是收還是發。
  2. 用郵件 header 當偵探——dashboard 會騙人,header 不會。寄封測試信,原始 header 裡寫得清清楚楚這封走了哪些服務。記得用網域地址寄,別用個人信箱,不然測了個寂寞。

殘留通常來自「評估過但沒採用的方案」——設定設了一半,淘汰後忘了清。盤點一次,把死記錄清掉,DNS 才乾淨。

補充:DNS 記錄類型速查

記錄 說明
MX 收信路由。告訴外部郵件伺服器「寄到這個網域的信,應該送到哪台 SMTP 伺服器」,數字優先度越小越優先,可設多筆做備援
TXT 純文字記錄,DNS 裡的萬用槽。SPF、DKIM 公鑰、網域所有權驗證都塞這裡
CNAME 別名記錄。把一個 subdomain 指向另一個 hostname,由對方那端負責最終解析。根網域(marsen.me 本身)不能用
SPF 發信授權白名單,格式寫在 TXT 裡(v=spf1 ...)。聲明哪些 IP 或服務有資格以這個網域寄信,收件方用來驗發件 IP
DKIM 郵件防偽簽章。發信服務用私鑰簽章,公鑰掛在 DNS(TXT 或 CNAME 委派),收件方取公鑰驗簽,確認內容未被竄改
A 最基本的記錄。把網域直接對應到一個 IPv4 位址
AAAA 同 A,對應的是 IPv6 位址
NS 管轄聲明。指定這個網域的 DNS 問題,由哪幾台 Name Server 負責回答

參考

(fin)

[實作筆記] 個人自動化平台(番外) 更新自架在 GCP VM 上的 n8n(Docker 版)

前情提要

自架 n8n 跑在 GCP VM 的 Docker container,
某天備份 log 出現這行:

1
Error tracking disabled because this release is older than 6 weeks.

意思是 n8n 的 release 超過六週就會關掉錯誤追蹤,提醒你該更新了。
趁機整理一下 Docker 版 n8n 的更新流程。


更新前先確認現況

確認目前版本

1
2
docker exec n8n n8n --version
# 2.16.1

確認 container 設定

更新要重建 container,所以要先抓出原本的設定:

1
2
3
4
5
6
7
8
9
docker inspect n8n --format '{{json .HostConfig.PortBindings}}'
# {"5678/tcp":[{"HostIp":"","HostPort":"5678"}]}

docker inspect n8n --format '{{range .Mounts}}{{.Source}}:{{.Destination}}{{end}}'
# /home/user/.n8n:/home/node/.n8n

docker inspect n8n --format '{{range .Config.Env}}{{println .}}{{end}}'
# N8N_SECURE_COOKIE=false
# ...(其他是 image 預設的,不用帶)

重點是找出三個東西:port、bind mount 路徑、自己設的 env。


更新三步驟

Step 1:pull 最新 image

1
docker pull docker.n8n.io/n8nio/n8n

Step 2:停掉舊 container

1
docker stop n8n && docker rm n8n

這時候 n8n 停機,資料不會消失,因為資料在 VM 本地的 bind mount 目錄,
跟 container 的生命週期完全無關。

Step 3:用相同設定重新啟動

1
2
3
4
5
6
7
docker run -d \
--name n8n \
--restart unless-stopped \
-p 5678:5678 \
-v /home/user/.n8n:/home/node/.n8n \
-e N8N_SECURE_COOKIE=false \
docker.n8n.io/n8nio/n8n

把 -v 的路徑換成你自己的 bind mount 路徑,env 只帶你自己加的就好。


驗證

1
2
docker exec n8n n8n --version
docker ps | grep n8n

確認版本號,確認 container 狀態是 Up,完成。


補充:備份

更新前要不要備份?

如果你有設 bind mount,資料就在 VM 本地,container 停掉不影響。
但如果更新後 n8n 有 migration 問題,舊 DB 可能回不去。

保險做法是更新前先手動跑一次備份:

1
2
3
4
5
6
7
# 匯出所有 workflow
docker exec n8n n8n export:workflow --all --pretty \
--output=/home/node/.n8n/exports/workflows/

# 匯出所有 credentials
docker exec n8n n8n export:credentials --all --pretty \
--output=/home/node/.n8n/exports/credentials/

或者搭一個 cron job 每天自動備份、git push,就不用每次更新前手動跑:

1
2
# crontab -e
0 3 * * * /home/user/n8n-backup/backup.sh >> /var/log/n8n-backup.log 2>&1

參考

小結

Docker 版 n8n 更新流程:

  1. docker pull — 拉新 image
  2. docker stop n8n && docker rm n8n — 移除舊 container
  3. docker run — 用相同設定重建

停機時間大約 30 秒。
關鍵是 bind mount:資料住在 VM,不住在 container,container 砍掉重練資料不受影響。

(fin)

[工具筆記] Prime Token vs MPG:兩種台灣電商金流模式的本質差異

前情提要

最近在電商專案要把虛構金流換成真實串接,比較了 TapPay、綠界、藍新三家。

比著發現原來存在著兩種金流串接的模式。

  • Prime Token 模式:TapPay、Stripe、Braintree
  • MPG(Merchant Payment Gateway) 模式:綠界 ECPay、藍新 NewebPay

兩種模式選錯,後面整個訂單系統的設計都會錯。


本質差異就一個問題:卡號從哪走?

兩種模式的根本問題只有一個:敏感卡號(PAN + CVV)的傳遞路徑。

MPG 型金流(綠界、藍新跳轉)對消費者反而更透明——畫面明確跳到金流商的頁面,消費者看得出來自己在哪。

但是在使用者體驗上,會多一個斷點,以電商來說會有轉換率的問題

Prime 型(TapPay iframe)的取捨是體驗好但透明度低,信任靠的是商家品牌,不是技術可見性。

而這是一個取捨。


實作細節

Prime Token 的卡號欄位內嵌結帳頁(實際是 iframe,能改邊框字體但限制多)。

整個結帳流程在你自己的頁面,設計語言、外觀風格一致。

MPG 直接跳到金流方頁面,你的品牌設計到那一刻全部斷掉。

但好處也很實際:同一個金流頁面能直接接信用卡 + ATM + 超商代碼 + LINE Pay。

兩者的開發量比較

Prime Token 你寫的東西:

  • 前端 SDK 載入 + mount 欄位
  • 後端一支 charge API
  • 一個結果頁

MPG 你多寫的東西:

  • 表單產生器(含簽章演算法)
  • 自動 submit 的轉址頁
  • NotifyURL endpoint(驗章 + 冪等 + 更新訂單,這支是核心)
  • ReturnURL endpoint(使用者回站只看結果,不可以當付款依據)
  • 訂單狀態機要多 awaiting_payment
  • 對帳邏輯(callback 沒到怎辦?要定時 query 嗎?)

MPG 看似簡單,實際工程量比 Prime Token 大,主要是 callback 的 edge case 多。

補充: PCI DSS 是什麼 ?

Payment Card Industry Data Security Standard,簡稱 PCI DSS。

信用卡組織(Visa、Mastercard 等)聯合制定的資安標準,規定任何「碰到卡號」的系統都要符合一堆安全要求。

核心概念:碰到卡號的範圍越小,你要過的關越少。

實務上只有金流商或大型電商會去取得這種認証,一般而言小型電商還是透過第三方金流來實現交易。


小結

  • 兩種模式的本質差異是卡號的傳遞路徑:Prime Token 走 SDK iframe,MPG 走表單轉址
  • 信任是最難的

(fin)

[踩雷筆記] gh CLI 操作 GitHub Projects 與 Actions 的授權

前情提要

想把個人 PBI 清單整合進 GitHub Projects,
同時設定 GitHub Actions 讓新 Issue 自動加進 Project 看板。
過程踩了幾個授權的坑,記錄一下。

授權流程

用 gh CLI 操作 GitHub Projects 和 Actions 需要分開授權,
預設的 token 不夠用。

Step 1:Project 讀寫權限

要新增 Issue 到 Project、建立 Project item,需要 project 和 read:project scope:

1
gh auth refresh -s project,read:project

執行後會跳出 device flow:

1
2
3
! First copy your one-time code: 70C4-9535
Press Enter to open https://github.com/login/device in your browser...
✓ Authentication complete.

Step 2:Workflow 修改權限

Push .github/workflows/ 目錄下的檔案需要額外的 workflow scope,
即使已有 repo 完整寫入權限也不夠:

1
gh auth refresh -s workflow

一樣走 device flow,完成後才能 push workflow 檔案。

為什麼要分開?

GitHub 的設計:CI/CD workflow 修改權限獨立出來,
防止 OAuth App 未經授權竄改 Actions。
錯誤訊息長這樣:

1
refusing to allow an OAuth App to create or update workflow

看到這個就知道要補 workflow scope。

GitHub Actions 自動加 Issue 進 Project

workflow 長這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
name: Add Issue to Project

on:
issues:
types: [opened]

jobs:
add-to-project:
runs-on: ubuntu-latest
steps:
- uses: actions/[email protected]
with:
project-url: https://github.com/users/marsen/projects/2
github-token: ${{ secrets.PROJECT_TOKEN }}

PROJECT_TOKEN 需要在 repo settings 裡設定 secret,
用一個有 project scope 的 PAT。

Fine-grained PAT 不適用

想用 fine-grained PAT 取代 classic PAT?這條路走不通。

actions/add-to-project 需要有 project scope 的 token。
Fine-grained PAT 的 Projects 權限在 Account permissions 區塊,
但 user-level project(/users/{user}/projects/N)目前不在支援範圍,
設定頁面根本找不到對應選項。

直接用 classic PAT,不要浪費時間。

私有 Repo 要加 repo scope

如果 Issue 所在的 repo 是私有的,光有 project scope 還不夠。
Action 需要讀取 Issue 的 node ID,而私有 repo 的 Issue 沒有 repo scope 就讀不到。

錯誤訊息長這樣:

1
2
Error: Request failed due to following response errors:
- Could not resolve to a node with the global id of '...'

Classic PAT 沒有「只讀私有 repo」的細粒度選項,
repo:status、repo_deployment 這些子 scope 都不夠用,
只能勾最上層的 repo(Full control)。

Repo 類型 需要的 scope
公開 project
私有 project + repo

小結

  • gh project 操作要先 gh auth refresh -s project,read:project
  • Push .github/workflows/ 要先 gh auth refresh -s workflow
  • 兩個都是 device flow,不能在非互動環境下跑(例如 Claude Code 的 ! 指令)
  • actions/add-to-project + user-level project → 只能用 classic PAT
  • 私有 repo → classic PAT 需同時勾 project + repo

(fin)

[實作筆記] LINE Messaging API 用量查詢:免費額度怎麼看,以及為什麼通知會停止

前情提要

用 LINE Bot 做系統通知,結果某天發現通知完全停了,重啟也沒用。
查了一圈才發現根本原因不是程式問題,這篇記錄怎麼查用量、以及為什麼停的。


問題現象

LINE Bot 的 push 通知在某個時間點之後完全靜默。
程式沒報錯,因為程式碼裡的 push 用了 .catch(() => {}) 吃掉所有錯誤:

1
2
3
async function push(text: string) {
await lineClient.pushMessage(ALLOWED_USER_ID, msg).catch(() => {})
}

靜默失敗,完全看不出來哪裡出問題。


怎麼查用量

LINE 的用量統計不在 Developers Console,要去 LINE Official Account Manager:

  1. 打開 manager.line.biz
  2. 選你的 Official Account
  3. 左側 → 分析 → 訊息

可以看到:

欄位 說明
合計(所有訊息) Reply + Push 總和
Push 主動推播的則數
Reply 回覆訊息的則數

可以選日期範圍,最長 60 天。


免費額度是多少

LINE Messaging API 免費方案每月有 500 則免費訊息(Push + Reply 合計)。

超過之後每則需要付費,或者訊息會被擋下來(依帳號設定而定)。

500 則聽起來很多,但如果 Bot 跑 AI 對話,Claude 回應很長,常常一次就切成好幾則 push(每則上限 5000 字),很快就會耗光。


這次的真正原因

查完用量才發現:5/1 用了 141 則,5/2 用了 59 則,5/3 之後全部是 0。

5/3 之後不是「發了但被擋」,是根本沒有觸發 webhook。

原因是 LINE Bot 用 Webhook 模式,需要一個對外的 HTTPS URL。
我的架構是:

1
LINE 伺服器 → HTTPS Webhook URL → Cloudflare Tunnel → 本機 Express server

Cloudflare Tunnel 的 launchd service 在 5/3 掛掉,LINE 打不到 webhook,所以什麼都沒發生。


Telegram 為什麼更穩

Telegram 用 Long Polling 模式,bot 主動去 Telegram 伺服器拉訊息,不需要對外開放任何 URL。

LINE Telegram
連線模式 Webhook(需要 tunnel) Long Polling(不需要)
斷線風險 tunnel 掛、IP 變、PORT 未開 幾乎沒有
免費額度 500 則/月 無限制

如果是用來做系統通知(不可中斷),Telegram 比 LINE 穩定很多。


小結

  • LINE 用量在 LINE Official Account Manager → 分析 → 訊息
  • 免費方案 200 則/月,用完靜默失敗
  • Webhook 模式依賴 tunnel,tunnel 掛掉通知就停
  • 對穩定性要求高的通知場景,Telegram Long Polling 更適合

(fin)

[AI生成]20260505 周報

本周要點

其他訊息

(fin)

[AI生成]20260504 周報

本周要點

其他訊息

(fin)

[AI生成]20260502 周報

本周要點

其他訊息

(fin)