- date
- entry
- 034
- topic
- integration
- rev
- —
EUR-Lex 回 202、0 位元組,curl 不報錯:改從 Cellar 抓歐盟法規全文
抓歐盟法規的腳本拿到 202 和一個空檔案,看起來像成功。那是 WAF 的 challenge。出版品辦公室的 Cellar 給得出同一份全文,但要問對格式。
讀歐盟法規,大家都去 EUR-Lex。網址裡帶 CELEX 編號(歐盟法律文件的編號,例如 32009R1223 是化妝品法規 (EC) No 1223/2009),要寫腳本自動抓,第一個念頭就是 curl 那個網址。
但 2026 年 10 月 13 日我這樣抓,拿到的是 202 Accepted 和一個 0 位元組的檔案,curl -f 的結束碼是 0。這篇要談的是這個 202 從哪裡來、為什麼常見的錯誤檢查都放它過去,以及改從歐盟出版品辦公室的 Cellar 抓全文時,怎麼問才拿得到東西。依序是:202 是什麼、哪些檢查會漏、Cellar 怎麼用、怎麼先查它有哪些格式。
202 是 WAF 的 challenge
完整的回應標頭(節錄):
$ curl -s -o eurlex.html -D - -A 'Mozilla/5.0' \
'https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:32009R1223'
HTTP/1.1 202 Accepted
Server: CloudFront
Content-Length: 0
x-amzn-waf-action: challenge
Cache-Control: no-store, max-age=0
X-Cache: Error from cloudfront
x-amzn-waf-action: challenge 是 AWS WAF 的標頭。AWS WAF 開發者指南〈CAPTCHA and Challenge action behavior〉寫得很清楚:請求沒有有效的 token 時,Challenge 動作會擋下請求,回應帶這個標頭、狀態碼 202 Request Accepted;如果請求的 Accept 標頭含 text/html,回應才會附上一頁跑 challenge 腳本的 JavaScript 插頁。
我照這個條件各試一次。沒有自己指定 Accept(curl 預設送 */*)時 body 是 0 位元組;帶 Accept: text/html,application/xhtml+xml 時拿到 2,035 位元組的 HTML,裡面是 challenge.js,沒有任何法規文字。兩次都是 202。EUR-Lex 自己的〈資料再利用〉說明頁和 robots.txt 用 curl 抓也是同樣的 202。這是給瀏覽器跑腳本的關卡,我沒有試著繞過它。
哪些檢查會讓它過去
202 落在 2xx,所以「狀態碼小於 400 就算成功」的檢查全部放行。我連打三次,加上 -f(伺服器回錯誤時讓 curl 失敗):
$ for i in 1 2 3; do
curl -sf -o out$i.html -w '%{http_code} %{size_download} ' \
'https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32026R0909'
echo "exit=$? bytes=$(wc -c < out$i.html)"
done
202 0 exit=0 bytes= 0
202 0 exit=0 bytes= 0
202 0 exit=0 bytes= 0
Python 標準函式庫的 urllib.request.urlopen 也一樣:回 status 202、讀到 0 位元組,不丟例外。如果腳本直接把它寫進檔案、交給下一步解析,錯誤可能要到解析器找不到條文時才浮現,而且看起來像解析器的問題,不像這個 202。這和〈會說話的失敗〉寫的是同一類:失敗長得像成功。
所以抓公開資料的腳本,我現在至少檢查四件事($url、$out 先設好):
code=$(curl -s -o "$out" -D "$out.h" -w '%{http_code}' "$url") || { echo "curl failed: $url" >&2; exit 1; }
[ "$code" = 200 ] || { echo "HTTP $code: $url" >&2; exit 1; }
grep -qi '^x-amzn-waf-action' "$out.h" && { echo "WAF challenge: $url" >&2; exit 1; }
[ -s "$out" ] || { echo "empty body: $url" >&2; exit 1; }
curl 本身要成功(它偵測得到的傳輸錯誤會讓它失敗,但內容完整與否還是要另外驗);狀態碼要等於 200,不是小於 400;有 WAF 標頭就停;body 不能是空的。真正要確認的是內容,例如全文裡該有的標題字串,這一步留給後面的解析去做。
Cellar:同一份文件的另一個入口
EUR-Lex 的文件存放在歐盟出版品辦公室的 Cellar。Cellar 的介面規格(出版品辦公室公開的〈Cellar Interface Specification〉,招標附件 17,R.1.0.6)定義了一組 REST 網址:http://publications.europa.eu/resource/celex/{CELEX},用 HTTP 的 Accept 選格式、Accept-Language 選語言,伺服器解析到對應的檔案再轉址過去。
我對三份文件、四種 Accept 各打一次(Accept-Language: eng,跟著轉址):
| CELEX | Accept | 結果 |
|---|---|---|
32009R1223(原始法規) | application/xhtml+xml | 200,2,154,972 位元組 |
02009R1223-20260518(彙編版) | application/xhtml+xml | 200,3,890,782 位元組 |
32026R0909(2026/909 修正案) | application/xhtml+xml | 200,242,629 位元組 |
| 三份都是 | application/xml;notice=branch | 200(metadata) |
| 三份都是 | text/html | 404,214 位元組 |
| 三份都是 | application/pdf | 404,214 位元組 |
404 的 body 說得很明白:
None of the requests returned successfully a redirection. The following exception
was thrown: [cellar identifier cellar:b8bc7010-429a-11f1-8095-01aa75ed71a1 does not
hold a content datastream of the requested type]
CELEX 認得,文件也在,只是沒有 text/html 這種格式。規格書裡 Accept 的範例第一個就是 text/html,PDF 範例的首選是 application/pdf;type=pdf1x(後面再以較低權重接 pdfa1a、pdfa1b);這次單獨要 text/html、pdf1x、pdfa1a,在這份 2026 年的法規上都是 404。好的一面是:要求不存在的格式時,Cellar 回的是 404,curl -f 會以結束碼 22 失敗,不會像 202 那樣混過去。
彙編版那份是我要的:標題是 Consolidated TEXT: 32009R1223 — EN — 18.05.2026,去掉標籤後約 53 萬字元,附錄 II 的禁用物質清單在裡面。
先問它有哪些格式
猜格式不如直接問。Accept: application/xml;notice=branch 回的是這份文件的 metadata:指定語言的版本,以及它有的格式(規格書叫 manifestation);這次的回應另外還嵌入了其他語言版本。2026/909 那份的 branch notice 列了 24 個語言版本(英文版另外在最上層完整展開一次,所以一共出現 25 次、75 筆 manifestation),每個版本都是同樣三種格式:
pdfa2a → …01aa75ed71a1.0006.01
fmx4 → …01aa75ed71a1.0006.02
xhtml → …01aa75ed71a1.0006.03
沒有 html,也沒有 pdf1x。照這張表改問,Accept: application/pdf;type=pdfa2a 回 303、轉到 .0006.01/DOC_1;type=pdfa1a 和 type=pdf1x 都是 404。fmx4 是出版品辦公室的 Formex XML。
兩個細節,寫腳本會碰到:
- 轉址的 Location 是
http://。303 指向http://publications.europa.eu/resource/cellar/…/DOC_1。curl-L會跟著降到 http;我直接改成 https 打同一個路徑,回 200、位元組數相同。在意傳輸加密的話,自己把轉址改回 https 再抓。 - 語言碼。我讀的 R.1.0.6 規格書寫
Accept-Language用 ISO 639 的兩碼,出版品辦公室較新的〈CELLAR End user manual〉(8.10.1,2023 年 12 月)用的是三碼(ISO 639-3);這次對 2026/909 用eng和en都轉到同一個檔案,下面的腳本照手冊用eng。換成fra,彙編版轉到另一個語言版本(.0010.02/DOC_2)。規格書另外寫了語言回退:指定的語言沒有那種格式時,會照權重往下找,全都沒有才 404。
我現在抓歐盟法規的寫法:先用 notice=branch 確認英文版有 xhtml,協商那一步要回 303 並帶 Location,下載要回 200、檔案非空、內容有預期的標題,任何一步不符就停(${loc/http:/https:} 是 bash 語法)。
celex=02009R1223-20260518
base="https://publications.europa.eu/resource/celex/$celex"
code=$(curl -sSL -o "$celex.branch.xml" -w '%{http_code}' \
-H 'Accept: application/xml;notice=branch' -H 'Accept-Language: eng' "$base") || exit 1
[ "$code" = 200 ] || { echo "branch: HTTP $code" >&2; exit 1; }
python3 - "$celex.branch.xml" <<'PY' || exit 1
import sys, xml.etree.ElementTree as ET
r = ET.parse(sys.argv[1]).getroot()
lang = r.findtext("./EXPRESSION/EXPRESSION_USES_LANGUAGE/URI/IDENTIFIER")
types = [m.findtext("MANIFESTATION_TYPE/VALUE") for m in r.findall("./MANIFESTATION")]
if lang != "ENG" or "xhtml" not in types:
sys.exit(f"no English xhtml: lang={lang} types={types}")
PY
out=$(curl -sS -o /dev/null -w '%{http_code} %{redirect_url}' \
-H 'Accept: application/xhtml+xml' -H 'Accept-Language: eng' "$base") || exit 1
code=${out%% *}; loc=${out#* }
[ "$code" = 303 ] && [ -n "$loc" ] || { echo "negotiation: HTTP $code" >&2; exit 1; }
code=$(curl -sS -o "$celex.en.xhtml" -w '%{http_code}' "${loc/http:/https:}") || exit 1
[ "$code" = 200 ] && [ -s "$celex.en.xhtml" ] || { echo "download: HTTP $code" >&2; exit 1; }
grep -q 'Consolidated TEXT: 32009R1223' "$celex.en.xhtml" || { echo "unexpected content" >&2; exit 1; }
如果只記得一件事
202 和結束碼 0 都不代表下載成功。這次 EUR-Lex 帶著 x-amzn-waf-action: challenge 的 202,是 WAF 擋下請求後的回應,body 是空的或是一頁 challenge 腳本,沒有法規全文;把「小於 400」當成功的檢查會把它寫進資料夾。要的是法規全文,就走 Cellar,先讀 branch notice 看它有哪些格式,再用 application/xhtml+xml 要。開頭那份原始法規這樣要得到 2,154,972 位元組;換成 2026 年 5 月 18 日的彙編版 CELEX,拿到的是 3,890,782 位元組、附錄 II 在裡面的版本。
修訂紀錄
- 首次發布