最終的結果是一個 CI 不會回寫任何內容到儲存庫的設定,所有狀態都只存在於 dev.to 上。在這篇文章中,我將逐步說明我做出這些決策的順序,以及我在整個過程中如何使用 Claude Code。
我讓 Claude Code 提出 30 多個問題來確定需求
我最初給 Claude Code 的請求只有這三行(原文為日文):我想要建立一個 GitHub Actions 工作流程,當文章被新增到 articles/ 給 zenn.dev 並成為「已發佈」狀態時,將其翻譯版本發佈到 dev.to。請以此作為參考。不要憑空猜測任何不清楚的內容,請與我確認所有事項。
我透過 Matt Pocock 的 Claude Code 技能集中的 grill-with-docs 執行了這個請求。顧名思義,這是一個透過提問來「拷問」使用者的技能,一次關閉一個設計分支。它不會一次問所有問題,只會問一批其先決條件已經確定的問題。例如,在決定「翻譯在哪裡發生?」之前,它不會問「我們在哪裡儲存翻譯 API 金鑰?」。
以下是我實際收到的一些問題:
| 問題 | Claude 的建議 | 我的回答 |
| :--------------------------------- | :------------------ | :------------- |
| 現有 18 篇已發佈文章是否也應跨平台發佈? | 否 | 否 |
| 如果原始文章發佈後被編輯,dev.to 是否也應更新? | 是 | 是 |
| 發佈到 dev.to 時是草稿還是立即發佈? | 草稿 | 立即發佈 |
| 圖片應從何處提供(儲存庫是私有的)? | 將儲存庫公開 | 不帶圖片 |
| 在 CI 中翻譯,還是在本地端翻譯? | CI | 本地端 Claude Code |
對於幾個問題,我的回答與建議不同。例如,建議使用草稿是「這樣你就可以審查機器翻譯一次」。但由於我決定在本地端翻譯,審查在提交之前就已經發生了。
「翻譯在哪裡發生?」這個問題是 Claude 在閱讀參考文章後才添加的。參考文章沒有在 CI 中進行翻譯,這與我的請求(「使用 Actions 翻譯並發佈」)相矛盾。這一個問題決定了後續一半的設計。
經過五輪提問,問題數量超過 30 個。每次確定答案後,Claude 都會將這些術語添加到 CONTEXT.md 中。該檔案明確定義了「原始文章」、「英文版本」、「發佈」和「跨平台發佈」等詞彙在這個儲存庫中的含義。你可以稱之為領域驅動設計中的「通用語言」。
對於日後難以逆轉的決策,它還記錄了兩個 ADR(架構決策記錄:簡短文件,記錄決策及其理由)。順帶一提,我正在撰寫一本關於 ADR 的 Zenn 書籍,完成後就會發佈。
grill-with-docs 會為每個問題附上建議答案。你只需回答「聽起來不錯」即可繼續,所以它不像問題數量暗示的那麼累人。反之,如果你不想盲目接受建議,請務必閱讀每個建議附帶的理由。
翻譯不留在 CI 中:我在本地端審查,然後提交
我最初粗略的請求是「使用 Actions 翻譯並發佈」,但最終我將翻譯從 CI 中移除了。決定性因素是選擇立即在 dev.to 上發佈。如果 CI 調用 LLM API 進行翻譯,那麼未經人工閱讀的英文文本將直接發佈。所以我將角色劃分如下:
寫原始文章 -> 翻譯成英文 -> 閱讀英文版本並提交 -> 推送到 main -> Actions 跨平台發佈到 dev.to
翻譯透過 Claude Code 技能 /translate <slug> 進行。該技能會寫出翻譯後的內容,一個名為 translate-apply.ts 的腳本會轉換 Zenn 語法,添加 frontmatter,並將結果寫入 articles_en/<slug>.md。該技能不會提交。我負責閱讀英文版本並提交,這是唯一的審查檢查點。
這樣一來,我就可以安全地使用我的 Claude Code 訂閱,而無需設定任何翻譯 API 金鑰,也沒有額外的使用成本。缺點是,如果我忘記翻譯,文章就只會停留在未跨平台發佈的狀態。我決定用稍後描述的「暫停」警告來解決這個問題。
不儲存 dev.to 文章 ID;透過 canonical_url 查詢
參考設定將 slug 和 dev.to 文章 ID 之間的映射儲存在 .devto-mapping.json 中,並且 Actions 會將該檔案提交回去。從第二次運行開始,它會使用該 ID 進行更新(PUT)。這種方法給我帶來了兩個問題。
首先,Actions 的提交會與我的本地 main 分支衝突。除非我在每次推送後都拉取,否則我的下一次推送將被拒絕。其次,如果發佈(POST)到 dev.to 成功,但提交映射失敗怎麼辦?在下一次運行時,找不到 ID,因此同一篇文章將再次發佈。
所以在我的設定中,我選擇完全不保留映射。每次跨平台發佈時,它都會從 dev.to API 獲取我的文章列表,並尋找其 canonical_url 與原始 Zenn 文章 URL 匹配的文章。canonical_url 是告訴搜尋引擎「這篇文章的原始來源在這裡」的 URL。由於每篇跨平台發佈的文章都設定了這個 URL,它可以用作匹配鍵。
GitHub Actions -> dev.to API: GET /api/articles/me/all
dev.to API --> GitHub Actions: 文章列表(包括未發佈的)
GitHub Actions -> GitHub Actions: 透過 canonical_url 與原始文章匹配
GitHub Actions -> dev.to API: POST 或 PUT(僅限有變更的文章)
這是否有效取決於 dev.to 端的列表 API 行為。如果未發佈的文章沒有包含在列表中,那麼一篇曾經被下架的文章在重新發佈時將被判斷為「不在 dev.to 上」,並被發佈兩次。僅憑文件不足以判斷,所以我讓 Claude 閱讀了運行 dev.to 的 Forem 原始碼:
- /api/articles/me/all 也會返回未發佈的文章(文件說「未發佈的文章將在列表頂部」)
- 回應包括 published、body_markdown、tag_list(一個陣列)和 canonical_url。來源:app/views/api/v1/articles/me.json.jbuilder
- body_markdown 完全按照發送的內容儲存;不會添加 frontmatter
這告訴我,差異檢查也可以透過一次列表獲取來完成。成本是每次跨平台發佈一次列表獲取,以及如果有人手動在 dev.to UI 中編輯 canonical_url,匹配就會失效。後者已作為操作規則記錄在 ADR 中。
只有原始日文文章持有發佈狀態
在參考設定中,英文版本的 frontmatter 也包含 published 欄位,CI 會驗證它是否與日文版本匹配。如果它們不匹配,發佈就會停止。在我選擇的選項中,英文版本根本沒有 published 欄位。Actions 總是讀取原始文章的 published 狀態,並讓 dev.to 與之匹配。
如果一開始只有一個來源,就沒有什麼需要檢查不匹配的地方。將原始文章的 published 設回 false,就可以同時在 dev.to 上下架。因此,英文版本的 frontmatter 只有三個欄位:
articles_en/.md
---
title: 'Why "Harness": a note'
tags:
source_hash: edf3ae74a6a6...
---
每篇文章的處理方式如下圖所示。只有「有英文版本的 slug」會被處理。現有的 18 篇文章沒有英文版本,因此它們自然地超出了範圍,無需任何排除列表。
有英文版本 -> 原始文章已發佈?
原始文章已發佈? -- 是 --> 存在於 dev.to 上?
原始文章已發佈? -- 未發佈或已刪除 --> 已發佈到 dev.to 上?
存在於 dev.to 上? -- 否 --> POST 並立即發佈
存在於 dev.to 上? -- 是 --> 內容已變更?
內容已變更? -- 是 --> 使用 PUT 更新
內容已變更? -- 否 --> 不做任何事
已發佈到 dev.to 上? -- 是 --> 使用 PUT 下架
已發佈到 dev.to 上? -- 否 --> 不做任何事
差異檢查比較四個方面:標題、內容、標籤和發佈狀態。參考設定在每次推送時都會 PUT 每篇已發佈的文章,但這也會不必要地更改 dev.to 上的更新時間戳。它還會讓你更接近 dev.to 的速率限制(預設為每 30 秒 9 次文章建立和 30 次更新)。
使用原始文章的雜湊值來捕捉過時的翻譯
有時我在發佈後修復了原始文章,卻忘記重新生成英文版本。為了捕捉這個問題,我在生成英文版本時記錄了原始文章的雜湊值,作為英文版本中的 source_hash。在跨平台發佈時,它會計算當前原始文章的雜湊值,如果不匹配則發出警告。
那麼這個雜湊值應該從什麼計算呢?首先想到的是整個原始檔案的雜湊值。但這樣一來,即使只是將 published: false 改為 true,雜湊值也會改變。如果你提前準備好翻譯,然後再發佈,每次都會收到「翻譯已過時」的警告。所以我將雜湊輸入範圍縮小到標題和內容:
$$ h = \mathrm{SHA256}\left(\mathrm{JSON}({\,\mathit{title},\ \mathit{body}\,})\right) $$
更改 published、topics 或 emoji 不會改變 $h$;它只在內容或標題改變時才改變。測試精確地說明了這個特性:
scripts/lib/hash.test.ts
it("published を切り替えてもハッシュは変わらない", () => {
const published = original.replace("published: false", "published: true");
expect(computeSourceHash(published)).toBe(computeSourceHash(original));
});
過時的英文版本仍會按原樣跨平台發佈,並帶有警告。如果跨平台發佈停止,那麼即使只修正一個錯字,dev.to 也會保持未更新狀態,直到翻譯重新生成。
透過測試確定 Zenn 語法轉換
dev.to 不理解 Zenn 自己的語法,因此需要進行轉換。
| Zenn | 在 dev.to 上 |
| :--------------------------------- | :----------------------------------------- |
| :::details タイトル | {% details タイトル %} |
| ... | ... |
| {% enddetails %} | {% enddetails %} |
| :::message (alert) | 以 ℹ️ (⚠️) 為前綴的區塊引言 |
| Fence info ts:src/index.ts | 粗體 src/index.ts 緊接在程式碼之前 |
| @card | 純連結 |
| @youtube 等 | {% embed URL %} |
| !alt | 註明「圖片請參閱原始 Zenn 文章」 |
圖片被替換為註釋,因為我管理 Zenn 文章的儲存庫是私有的,所以 dev.to 無法從中獲取圖片。將儲存庫公開是一個選項,但這也會暴露未發佈的草稿,所以我放棄了這個選項。
轉換不交給 Claude Code 處理;它是一個基於正規表達式的腳本。使用 LLM,無法保證相同的輸入每次都會產生相同的輸出。確定性程式或腳本可以讓你透過測試來確定其行為。
轉換中最棘手的部分是「程式碼區塊內的任何內容都不會被轉換」的限制。有時,就像在這篇文章中一樣,我會編寫解釋 Zenn 語法本身的程式碼範例:
``` md
:::details これは記法の説明なので、変換してはいけない
:::
```
所以我採用了一種有點繁瑣的機制:在轉換之前,每個程式碼區塊都會被暫存起來,並替換為一個單行佔位符(一個數字,用 Unicode 私用區字元 U+E000 和 U+E001 包裹),並在轉換完成後恢復。
暫存和恢復程式碼(摘錄)
scripts/lib/convert.ts
export function convertZennToDevto(markdown: string, options: ConvertOptions): string {
const { text, blocks } = stashCodeBlocks(markdown);
const converted = [
(t: string) => convertLocalImages(t, options.originalUrl),
convertEmbeds,
convertDetails,
convertMessages,
].reduce((acc, convert) => convert(acc), text);
return converted.replace(PLACEHOLDER, (_m, prefix: string | undefined, i: string) => prefix ? quoteLines(pref