- date
- entry
- 051
- topic
- integration
- rev
- —
用 WordPress REST API 數一個站有幾篇文章:總數一個 HEAD 就有,有結果的查詢翻過頭回 400 不是空陣列
要知道一個 WordPress 站有幾篇文章,直覺做法是從第 1 頁開始翻,翻到空的為止。我對 wordpress.org/news 的 REST API 實測,翻過最後一頁拿到的是 HTTP 400 和一個錯誤物件,不是空陣列;總數其實一個 HEAD 請求就給了。
WordPress 的 REST API 列文章是 /wp-json/wp/v2/posts,一頁預設 10 篇,用 page 翻頁。要數一個站有幾篇,很直覺的寫法是:從第 1 頁開始,每次加一,拿到空陣列就停。
但我對 wordpress.org 的官方部落格(wordpress.org/news)實測,翻過最後一頁時拿到的是 HTTP 400 和一個 JSON 物件,不是 []。這篇要談的是怎麼用這個 API 把一個站的文章數數對,依序是:總數從哪裡拿、一頁最多幾篇、翻過頭會怎樣、抓完怎麼檢查,以及分類的數字為什麼對不上文章數。所有請求都是 10 月 18 日凌晨(台灣時間)發的,沒有登入、沒有帶認證,看到的是這個查詢公開能取得的已發布文章。
總數在標頭裡,HEAD 就拿得到
REST API Handbook 的 Pagination 頁說,每個分頁的回應都帶兩個標頭:X-WP-Total 是總筆數,X-WP-TotalPages 是總頁數。實際回應:
$ curl -s -D - -o /dev/null "https://wordpress.org/news/wp-json/wp/v2/posts?per_page=100&_fields=id"
HTTP/2 200
x-wp-total: 1110
x-wp-totalpages: 12
link: <https://wordpress.org/news/wp-json/wp/v2/posts?per_page=100&_fields=id&page=2>; rel="next"
只要總數的話,連內容都不用抓。改用 HEAD、per_page=1,回應一樣有 x-wp-total: 1110(這時 x-wp-totalpages 是 1110,因為一頁一篇)。WordPress 原始碼(wordpress-develop trunk 的 class-wp-rest-posts-controller.php)對 HEAD 請求只查文章 ID,不準備內容;只要總數時,不必讓伺服器組出整頁文章再丟掉。不帶 per_page 時一頁 10 篇,同一個站是 111 頁。
一頁最多 100 篇,多一篇就 400
Handbook 寫 per_page 是 1 到 100 的整數。超過不會被默默改成 100,而是直接拒絕:
$ curl -s ".../posts?per_page=101&_fields=id"
{"code":"rest_invalid_param","message":"Invalid parameter(s): per_page",
"data":{"status":400,"params":{"per_page":"per_page must be between 1 (inclusive) and 100 (inclusive)"}, …}}
所以要把 1,110 篇全部抓回來,最少要 12 個請求。每一頁要的欄位也值得限定:per_page=100 不帶 _fields 的第一頁是 2,341,785 位元組,帶 _fields=id,date,link 是 12,267 位元組,差將近 200 倍。只是數數或建清單,不需要每篇的全文 HTML。
翻過頭是 400,不是空陣列
Handbook 的 Pagination 頁沒有寫頁碼超過總頁數時會怎樣。實測從第 12 頁翻到第 14 頁:
page 12: [{"id":10},{"id":9},{"id":1432},{"id":1431}, … [200]
page 13: {"code":"rest_post_invalid_page_number","message":"The page … [400]
page 14: {"code":"rest_post_invalid_page_number","message":"The page … [400]
只靠「拿到空陣列就停」的迴圈在這裡停不下來。第 13 頁回的錯誤 JSON 解析成一個非空的 dict,if not data: break 的條件是假,迴圈會繼續要第 14、15 頁,每一頁都是同樣的 400。如果 HTTP 函式庫遇到 400 會丟例外,結果是腳本在最後報錯;不丟、又沒有檢查狀態碼或設頁數上限的話,就是一直翻下去。
空陣列也不是完全不會出現。原始碼裡這個錯誤的條件是「頁碼大於總頁數,而且總數大於 0」。同一個站搜尋一個不存在的字串(search=qzxjvkw,X-WP-Total 是 0),第 1 頁和第 2 頁都回 200 和 [];篩一個不存在的分類 categories=1 也一樣。所以同一支迴圈,對 0 筆的查詢會正常結束,對有結果的查詢會出錯,測試時拿空的查詢跑會看不出問題。
停止條件改用標頭比較穩:讀第一頁的 X-WP-TotalPages,翻到那一頁就停;或者看 Link 標頭,這次的第 12 頁只有 rel="prev",沒有 rel="next"。不管用哪一個,HTTP 錯誤都要另外處理。
抓完怎麼檢查
翻完 12 頁,前 11 頁各 100 筆,第 12 頁 10 筆,共 1,110 筆,ID 沒有重複,date 欄位(站台時區)從 2003-04-06 到 2026-10-06。每一頁的 X-WP-Total 都是 1110,抓完再用 HEAD 問一次也是 1110。第一頁讀到的總數、最後重讀的總數、筆數、不重複的 ID 數四個一致,是我對這次抓取做的檢查。
為什麼要比對四個?因為這個 API 的分頁是位移式的:per_page=5&page=4 等於 per_page=5&offset=15(Handbook 的例子),預設照發布日期由新到舊排。用假設的數字說明:如果抓到第 3 頁時站上剛好發布一篇新文章,所有文章往後移一格,第 4 頁的第一篇就是第 3 頁的最後一篇,你會拿到一筆重複、筆數變成 1,111;反過來,如果是已經抓過的某篇在中途被刪掉,後面的文章往前移一格,交界那一篇就被跳過:筆數、不重複 ID、最後重讀的總數三個都是 1,109,彼此一致,只有和第一頁讀到的 1,110 比才看得出來。所以只比筆數和不重複 ID 擋得住重複,擋不住跳過;要加上「抓取前後的總數有沒有變」。四個數字一起比,仍然擋不住同時發生的一增一刪:已抓過的某篇被刪、同時前面多了一篇新的,前後總數都是 1,110,筆數和不重複 ID 也都是 1,110,結果卻留著一篇已刪的文章、漏掉一篇新的。所以這些是一致性檢查,不是證明;這次的回應沒有出現重複 ID 或數字不一致,但我不能排除抓取那幾十秒裡站上有這種一增一刪。
同一個時間的文章也會讓順序不穩定,這個站有一對:date 都是 2023-01-16 12:00:00 的 14175 和 14180,這次落在第 3 頁的第 52、53 筆,同一頁裡,不影響。如果剛好跨在兩頁的交界,兩次請求之間的排序不一定相同,這時用 orderby=id 翻頁可以消除同時間的排序歧義(但擋不住上面那種位移);我這次沒有遇到,沒有實測跨頁的情況。
分類的數字為什麼對不上
另一個會想到的捷徑是讀 /wp/v2/categories,把每個分類的 count 加起來。這個站這次回了 22 個分類,count 加總是 1,759,比 1,110 多了 649。
原因是一篇文章可以有好幾個分類。我把 1,110 篇的 categories 欄位也抓下來數:585 篇只有 1 個分類,417 篇有 2 個,94 篇有 3 個,12 篇有 4 個,2 篇有 5 個;每篇的分類數加起來正好是 1,759,和分類那邊的加總一致。換句話說,分類的 count 數的是「掛在這個分類下的文章」,加總數的是「文章和分類的配對」,不是文章數。要文章數,就看 posts 的 X-WP-Total。
另外,posts 只包含文章,不包含頁面。這個站的 /wp/v2/pages 的 X-WP-Total 是 2,要算「整站有多少內容」得先定義範圍,頁面和其他自訂內容類型都要分開查。
如果只記得一件事
數 WordPress 文章,總數用 HEAD 讀 X-WP-Total;翻頁時用 X-WP-TotalPages 或 Link 決定停在哪,不要等空陣列:有結果的查詢翻過頭回的是 400。抓完比對筆數、不重複 ID,以及抓取前後兩次讀到的總數;四個一致是必要的檢查,但擋不住抓取中途的一增一刪。
修訂紀錄
- 首次發布