marvinswift.com App API (v1)

給 Flutter app 消費用的靜態 JSON API。這裡的內容全部是原始 Markdown (不是 rendered HTML,也不是結構化 blocks),read path 完全走 GitHub Pages 的靜態 CDN——這一版沒有任何 Cloudflare Worker。

建議的同步流程

  1. 先拉 index.json(只有幾 KB)。
  2. 跟本機存的上一次 index.json 比對每個 endpoint 的 hash (sha256 前 16 碼,比 count/latest 更可靠——內容改了但篇數、 最新日期都沒變的情況,hash 一定會變,count/latest 不一定會變)。
  3. 只有 hash 不同的 endpoint 才需要重抓;沒變的直接跳過,不用發任何請求。
  4. 實際發 GET 請求時帶上本機存的 ETagIf-None-Match),讓沒有更新的 請求可以拿到 304,不用重新下載 body。GitHub Pages 本身就會對靜態檔案 回傳 ETag,這一步不需要額外設定。
  5. posts.json 可能會分頁(見下方「分頁規則」),分頁後每一頁也各自有 自己的 hash,可以只重抓真的變動的那一頁。

因為全站 raw markdown 只有大約 1.5 MB(gzip 後約 400–500 KB),這個同步 流程的終點是「整包抓回來存進本機 SQLite,離線可用」,不是每次都做增量 API 呼叫;index.json 的 hash 比對只是用來決定「要不要整包重抓」。

Endpoint 一覽

Endpoint 內容 大小量級
index.json manifest,列出所有 endpoint 的 count/latest/bytes/hash 幾 KB
zh/summaries.json 中文文章列表,不含全文 數百 KB
en/summaries.json 英文文章列表,不含全文 數十 KB
zh/posts.json(可能分頁) 全部中文文章,含完整 Markdown 約 1.5 MB,已分頁
en/posts.json 全部英文文章,含完整 Markdown 數百 KB
categories.json 五個分類的雙語 metadata 幾 KB
videos.json YouTube 影片/Shorts(轉自 _data/youtube.json 約 10 KB
series/agent-build-log.json Agent Build Log 連載,中英文成對收錄 約 100 KB
search-index.json 雙語合併的輕量離線搜尋索引 約 170 KB

index.json

{
  "api_version": 1,
  "generated_at": "2026-08-07T18:11:51.843130+00:00", // ISO8601 UTC,這次 generator 執行時間
  "build_id": "733f31ded444a6a7",                       // 所有 endpoint hash 再取 hash,一個字串代表「這整包 API 的版本」
  "site": {
    "url": "https://www.marvinswift.com",
    "title": "Marv[in]sight",
    "languages": ["zh", "en"]
  },
  "endpoints": {
    "zh/summaries.json": {
      "path": "/api/v1/zh/summaries.json",
      "count": 193,
      "latest": "2026-08-06T23:36:25+08:00", // 這個 endpoint 裡最新一篇文章的 date,null 表示不適用(如 categories.json)
      "bytes": 465961,
      "hash": "5177a797e24f5132"              // sha256(檔案內容) 取前 16 碼
    },
    "zh/posts.json": {
      // 分頁時沒有單一 path,見下方「分頁規則」
      "paginated": true,
      "page_count": 2,
      "pages": [
        { "path": "/api/v1/zh/posts-1.json", "count": 184, "latest": "...", "bytes": 908359, "hash": "..." },
        { "path": "/api/v1/zh/posts-2.json", "count": 9,   "latest": "...", "bytes": 640733, "hash": "..." }
      ],
      "count": 193,     // 全部分頁加總
      "latest": "...",
      "bytes": 1549092, // 全部分頁加總
      "hash": "..."     // 各分頁 hash 再串接起來取 hash,代表「整個 zh/posts.json 邏輯上的版本」
    },
    "en/posts.json": {
      "path": "/api/v1/en/posts.json",
      "paginated": false,
      "count": 87,
      "latest": "...",
      "bytes": 383137,
      "hash": "..."
    }
  }
}

分頁規則

規格是「單一 {lang}/posts.json 序列化後超過 1 MB 才分頁」。目前只有 zh/posts.json(約 1.48 MB)超過門檻,切成 zh/posts-1.json + zh/posts-2.jsonen/posts.json(約 383 KB)沒有超過,維持單一檔案。

分頁不是照篇數平分,是照每篇文章實際序列化後的位元組數累加著切, 確保每一頁都在門檻以內(文章長度差異很大,Agent Build Log 系列很長、 2013 年代的老文章通常很短,單純均分篇數沒辦法保證每頁都在 1 MB 以內)。

app 端判斷邏輯:讀 index.json.endpoints["{lang}/posts.json"],如果有 paginated: true,就照 pages 陣列依序抓每一頁再合併成完整文章陣列; 如果是 paginated: false,直接用 path 抓單一檔案。未來哪天英文文章 也超過 1 MB,會自動變成分頁格式——app 端不應該假設任何一個語言永遠是 單檔或永遠是分頁,一律先看 index.json 裡的 paginated 欄位。

文章物件 schema

zh/posts.json / en/posts.json(分頁後每一頁陣列裡的元素)、 zh/summaries.json / en/summaries.json(拿掉 content 欄位的輕量版) 共用同一個文章物件形狀:

欄位 型別 可為 null 說明
slug string 從檔名去掉日期前綴、去掉副檔名。注意:極少數舊文件名不小心打成兩層 .md.md,這裡會如實保留成 slug 裡帶 .md(例如 data-intensive-applications.md),因為正式網站上的網址本來就長這樣,見下方「已知限制」
title string 沒有 front matter title 時,退回用 slug 轉 Title Case
lang string "zh""en"
date string ISO8601,含 timezone offset
category string 五個分類之一:life/swift/programming/finance/unitTesting;極少數壞資料清理後仍取不到值時為 null(目前沒有這種案例,但 app 端要防呆)
tags string[] 可能是空陣列
summary string 優先順序:front matter summarydescription → 從正文抽的前 N 字
url string 絕對網址,跟正式網站的 permalink 完全一致(已用本機 Jekyll build 逐篇比對過,見交付說明)
hero_image string 正文裡第一張圖片的絕對 URL,抽不到給 null
content string 正規化後的 Markdown 全文(只有 posts.json 有這個欄位,summaries.json 沒有
content_format string "markdown""markdown+html",見下方說明
inline_html_tags string[] content_format"markdown+html" 時,列出正文裡殘留的 HTML tag 名稱(小寫);乾淨的文章是空陣列
has_insecure_images boolean 正文裡是否有 http://(非 https)的外部圖片
word_count number 中文字元數 + 英文單字數的合計(見下方「字數估算方式」)
reading_minutes number 概略閱讀分鐘數,至少為 1
source_path string 對應的 _posts/ 原始檔相對路徑,方便除錯追溯,app 端通常用不到

content_format / inline_html_tags / has_insecure_images 這幾個旗標怎麼用

這個部落格有一批 2013–2015 年從 Blogspot / Medium 搬過來的舊文章,原始 Markdown 裡混了不少殘留的 inline HTML(YouTube/Twitter embed 用的 <iframe>/<script>、排版用的 <div>/<p>/<h3> 等等)。這些沒辦法 安全轉成純 Markdown(转了会丢内容或丢版面),所以原樣保留在 content 字串裡,用旗標讓 app 端自己決定怎麼處理:

has_insecure_images 是給 iOS App Transport Security(ATS)/Android cleartext traffic policy 用的:這批舊文章裡有一些圖片還連到 http:// 的 Blogspot/Medium CDN(bp.blogspot.comcdn-images-1.medium.com 等),沒有 https 版本可用,所以故意保留原始 URL,不會自作聰明改寫成 https(外部 CDN 不一定支援 https,硬改會直接 變成破圖)。app 端看到這個旗標為 true,可以選擇:跳過該圖片改顯示 placeholder、或在 app 的 network security config / Info.plist 裡對這幾個 已知網域開白名單允許 cleartext。

字數估算方式

word_count = 中文字元數(CJK Unicode 範圍)+ 英文單字數(按空白斷詞)。 reading_minutes 用概略閱讀速度換算(中文抓 300 字/分鐘、英文抓 200 字/分鐘),無條件捨去後至少回傳 1。這是粗略估算,不是精確值。

categories.json

[
  {
    "slug": "life",
    "title_zh": "生活記事", "title_en": "Life Stories",
    "url_zh": "https://www.marvinswift.com/life/",
    "url_en": "https://www.marvinswift.com/en/life/",
    "count_zh": 26, "count_en": 3
  },
  {
    "slug": "finance",
    "title_zh": "財經新聞與投資筆記", "title_en": null,
    "url_zh": "https://www.marvinswift.com/finance/", "url_en": null,
    "count_zh": 18, "count_en": 0
  }
  // ... life / programming / swift / finance / unitTesting
]

finance 分類目前只有中文文章,title_en/url_ennull

videos.json

_data/youtube.json(既有的 .github/workflows/youtube-data.yml 維護)轉形狀而來:

{
  "generated_at": "2026-08-07T18:11:51+00:00",
  "videos": [
    { "id": "qualBRg758I", "title": "...", "published": "2026-07-13T17:00:09+00:00",
      "thumbnail": "https://...", "description": "...", "url": "https://youtu.be/qualBRg758I" }
  ],
  "shorts": [ /* 同樣形狀 */ ]
}

series/agent-build-log.json

Marvin 的每日連載,中英文成對收錄(同一集的 zh/en 版本用同一個 slug 對應):

{
  "slug": "agent-build-log",
  "title_zh": "Agent Build Log", "title_en": "Agent Build Log",
  "episode_count": 23,
  "episodes": [
    { "episode": 23, "slug": "agent-build-log-episode-023",
      "zh": { /* 完整文章物件,含 content */ },
      "en": { /* 完整文章物件,含 content */ } }
    // ... 按集數由小到大排序
  ]
}

如果某一集只有其中一個語言版本,另一個語言的欄位會是 null(目前 23 集全部都是中英雙語,這個情況現在不會發生,但 app 端要防呆)。

search-index.json

雙語合併、只含 metadata 的輕量索引,欄位是文章物件的子集合: sluglangtitlecategorytagssummaryurldateword_count

設計理由:完整全文已經在 posts.json 裡了,這份索引的價值是「一個檔案 就能拿到全站雙語的可搜尋欄位」,不用為了做關鍵字比對/自動完成,就先分別 抓 zh/posts.json(可能還要處理分頁)跟 en/posts.json 兩份大檔。

這不是全文檢索(full-text search)索引,只有 title/summary/tags 這幾 個欄位。如果要做正文全文搜尋,建議 app 端把 posts.json 下載、存進本機 SQLite 之後,用 SQLite FTS5 對 content 欄位建索引,在裝置端做。

Cloudflare Cache Rules 建議

GitHub Pages 對外回應的 Cache-Control 是固定的(max-age=600, 10 分鐘),沒有辦法透過 _config.yml 或任何 repo 內設定去改。如果 這個網域前面沒有額外的 CDN 快取層覆蓋這個行為,最壞情況會是:新文章 發布後,app 最久要等 10 分鐘快取才會過期,才抓得到新版 index.json; 反過來說,如果哪天想要更積極地快取大檔案省流量,GitHub Pages 這個固定 10 分鐘的上限也會擋住你。

這個網域本來就是掛在 Cloudflare 後面(marvinswift.com 的 DNS/CDN), 所以建議透過 Cloudflare Cache Rules(不是 Page Rules,Cache Rules 是比較新、可以用 Rulesets API 管理的功能)針對 /api/v1/** 覆蓋 origin 的 Cache-Control

路徑 pattern 建議設定 理由
/api/v1/index.json 短 TTL(例如 60 秒)或直接 bypass cache 這是同步流程的入口,必須盡快反映最新內容,檔案本身只有幾 KB,就算 bypass 也不會有明顯流量成本
/api/v1/*/posts*.json/api/v1/*/summaries.json/api/v1/search-index.json 等大檔 長 TTL(例如 1 天)+ stale-while-revalidate(例如再加 1 天) 這些檔案的「有沒有更新」完全由 index.json 的 hash 決定,app 不會盲目直接打這些大檔案;設長 TTL 可以大幅降低 GitHub Pages origin 的流量與延遲,stale-while-revalidate 確保 edge cache 過期的瞬間仍然先回舊內容給使用者,背景再更新,避免所有請求同時打回 origin
/api/v1/categories.json/api/v1/videos.json 中等 TTL(例如 10–30 分鐘) 更新頻率介於 index 跟大檔之間

實際請求量很小(單一使用者的個人 app,不是公開高流量服務),這組設定 主要是為了「發新文章後多久看得到」跟「省 GitHub Pages 流量」之間取一個 合理的平衡,不是為了扛流量尖峰。

老文章正規化:已知限制

這批從 Blogspot / Medium 搬過來的舊文章(主要集中在 2013–2015,以及少數 2018 年以前的文章)做了以下正規化,全部只作用在寫進 JSON 的字串上, 絕對不會改動 _posts/ 下的原始檔案

  1. 巢狀圖片連結 [![](inner)](outer) 攤平成單純的 ![alt](url),保留原本 的 alt text(如果有的話)。外層 URL 有兩種情況,取哪一個要分開判斷:
    • 外層也是圖片(Blogspot 老文的標準格式:內層 s320 縮圖、外層 s1600 原圖)→ 取外層,拿到解析度更好的版本。
    • 外層是網頁(2023 年之後的文章常見,例如 [![截圖](/assets/foo.png)](https://example.com/))→ 取內層。 判斷方式是看副檔名,再加上一份「不帶副檔名但確定回傳圖片」的 CDN 白名單(Medium / Blogspot 圖床);判斷不出來就當成不是圖片,讓內層 那個確定是圖片的路徑勝出。
  2. /assets/... 這種站內相對路徑的圖片,補上 https://www.marvinswift.com 前綴變成絕對 URL。
  3. http:// 的外部圖片(Blogspot/Medium 圖床)保留原始 URL 不做任何 改寫,用 has_insecure_images 旗標告知 app。
  4. 獨立成一行的 <hr>/<hr/>/<hr /> 轉成 Markdown 的 ---
  5. <br>/<br/>/<br /> 轉成真正的換行。
  6. <strong>/<b> 轉成 **粗體**<em>/<i> 轉成 *斜體*
  7. 上面 4–6 沒辦法涵蓋的 inline HTML(<iframe><script><div>、排版用的 <p>/<h3>/<small> 等等)原樣保留,靠 content_format/inline_html_tags 讓 app 端自己決定怎麼處理。
  8. 行尾雙空白(Markdown 的隱性硬換行)轉成明確的反斜線硬換行;純空白的 排版用空行收斂成真正的空行。避免依賴「trailing whitespace 不會被 trim 掉」這種脆弱假設(很多工具鏈、包括 git 本身的某些設定,都會把 行尾空白吃掉)。

已知限制 / 邊界案例(誠實列出,不是每一項都有辦法在 regex-based 的正規化裡完美處理):

這支腳本如何驗證跟正式網站一致

url 欄位是重新實作 Jekyll 的 permalink 解析邏輯算出來的(GitHub Actions runner 上沒有裝 Ruby/Jekyll,沒辦法直接用 Jekyll 本身算)。這份 邏輯已經用本機 bundle exec jekyll build 實際產生的 280 篇文章真實 URL (透過一個一次性的 Liquid 樣板列印每篇文章的 post.url,測試完就刪除, 沒有進到任何 commit 裡)逐篇比對過,280 篇全部相符,才確認可以信任 這份重新實作的邏輯。往後如果 _config.yml 的 permalink 規則有變動, 建議用同樣的方式重新驗證一次。