Claude CodeにETag付きのRSS巡回を書かせる — 304の割合で効果を測る
feedparserにETagとLast-Modifiedを渡す巡回スクリプトをClaude Codeに書かせ、20回の巡回で304が何割返るかを実測する。ETagが毎回変わるサーバーで効かなくなる例も。
RSSやAtomを定期巡回するスクリプトは、何も考えずに書くと毎回フィード全体をダウンロードします。ETagとLast-Modifiedを保存して次のリクエストに付ければ、変化がない回は本文なしの304で済みます。
この記事では、feedparserで条件付きGETを使う巡回スクリプトをClaude Codeに書かせ、権限を絞って動かし、304がどれだけ返ったかで効果を測るところまでを扱います。手元で20回巡回した結果、条件付きで17回が304になり、転送量は約14%に収まりました。
特定のフィードを要約する流れはe-GovのパブリックコメントRSSをClaude Codeで週次監視し要約するにあります。ここで扱うのは、その手前の「変わっていないときに何も取らない」取得の層です。サーバー側のCache-Control設計はClaude CodeにCache-Controlを点検させるの領分で、本記事は巡回する側の実装です。
条件付きGETで何が省けるか
フィードの配信元は、応答にETagやLast-Modifiedというヘッダを付けられます。クライアントが次回のリクエストでその値を送り返し、フィードが変わっていなければ、サーバーは304と本文なしの応答を返します。feedparserのドキュメントは、これを使わない巡回を「変わっていないフィードを繰り返しダウンロードし、自分と配信元の帯域を無駄にする。配信元からアクセスを拒否されることもある」と説明しています。
仕組みはHTTPの標準です。MDNの整理では、ETagは各バージョンを識別する不透明な文字列で、リクエスト側では If-None-Match(ETag)と If-Modified-Since(日付)の2つのヘッダで送ります。リソースが変わっていなければ304 Not Modifiedが返ります。
| 送るヘッダ | 保存しておく値 | feedparserの引数 |
|---|---|---|
If-None-Match | 保存しておく値前回の ETag | feedparserの引数etag= |
If-Modified-Since | 保存しておく値前回の Last-Modified | feedparserの引数modified= |
feedparserのドキュメントは「サーバーによってはどちらか一方しか対応しないので、両方を扱うべき」としています。スクリプトは「両方を保存して、あるものを送る」形にしておくと、配信元を選びません。
先にcurlで304を目で確かめる
スクリプトを書く前に、配信元が条件付きGETに応じるかを1回確かめます。ETagとLast-Modifiedを返すか、そのETagを送り返すと304になるか、の2点です。以下は、手元に立てた検証用のローカルサーバー(Python標準ライブラリで自作したもの)に対する実行結果です(curl 8.7.1、2026年10月28日)。
curl -s -D - -o /dev/null http://127.0.0.1:8780/feed.xml初回は200で、次のヘッダが返りました。本文は3,025バイトです。
HTTP/1.0 200 OK
Content-Type: application/rss+xml
ETag: "fd279d741fd6"
Last-Modified: Mon, 21 Sep 2026 14:13:20 GMT
Content-Length: 3025このETagを If-None-Match に付けて同じURLを取り直すと、本文なしの304になります。
curl -s -D - -o /dev/null \
-H 'If-None-Match: "fd279d741fd6"' \
http://127.0.0.1:8780/feed.xmlHTTP/1.0 304 Not Modified
ETag: "fd279d741fd6"
Last-Modified: Mon, 21 Sep 2026 14:13:20 GMT実在のフィードに対しても、同じ2回のリクエストで対応状況を判定できます。1回目に ETag も Last-Modified も出ない配信元は、そもそも条件付きGETの対象外です。その配信元に対しては、巡回間隔を空けて取得回数そのものを減らす方向で考えます。
Claude Codeに巡回スクリプトを書かせる
ここからが依頼の本体です。プロンプトには、仕様を箇条書きで固定します。条件付きGET(ETagとLast-Modified)と記事IDの比較は別の層の仕組みです。「差分だけ」と書くだけでは、どちらを指すのか区別がつかないためです。
poll.py を作って。引数は URL, 状態ファイル, cond|plain。
- feedparser.parse に etag と modified を渡す(cond のときだけ)
- status が 200 のときだけ、状態ファイルに etag と modified を保存する
- 標準出力は "<status> <エントリ数>" の1行だけ
- 通信エラーで status が無いときは None を出す出来上がるスクリプトの核は次の形です。以下は手元の検証で使ったものです。
import json, sys, feedparser
from pathlib import Path
URL, STATE = sys.argv[1], Path(sys.argv[2])
conditional = sys.argv[3] == "cond"
st = json.loads(STATE.read_text()) if STATE.exists() else {}
d = feedparser.parse(
URL,
etag=st.get("etag") if conditional else None,
modified=st.get("modified") if conditional else None,
)
status = getattr(d, "status", None)
if status == 200:
STATE.write_text(json.dumps(
{"etag": getattr(d, "etag", None),
"modified": getattr(d, "modified", None)}))
print(status, len(d.entries))d.etag と d.modified は、feedparserのドキュメントが示す取得方法そのままです。cond|plain の切り替えを入れたのは、同じスクリプトで「条件付きなし」の基準値を取るためです。
権限を絞って動かす
この作業でClaude Codeに許すのは、スクリプトの実行とcurlによるヘッダ確認くらいです。claude -p の --allowedTools に、権限ルールと同じ書式で渡せます。
claude -p "poll.py を作り、検証用URLで cond と plain を各1回実行して結果を報告" \
--allowedTools "Read,Edit,Write,Bash(.venv/bin/python poll.py *),Bash(curl -s -D - -o /dev/null *)"headlessのドキュメントによれば、Bash(git diff *) のような末尾の * は前方一致で、* の前の空白が重要です(空白がないと git diff-index にも一致してしまう)。この書式を .venv/bin/python poll.py * に当てはめれば、そのスクリプト以外のPython実行は許可に入りません。権限のルールが見るのはコマンドの文面なので、スクリプトの中身が通信先を増やしても止まらない点は、別の対策(通信先の制限)で補います。
20回巡回して304の割合を測る
効果は、巡回の記録で測ります。検証では、配信側のフィードを2回更新しながら(6回目と14回目の巡回の直前)、同じURLを20回取りました。エントリ数は5件から10件、15件と増え、更新のない回が大半を占める設定です。条件付きあり、なし、そしてETagが毎回変わる配信元の3通りで比べました。
| 条件 | 304の回数 | 転送した本文 |
|---|---|---|
| 条件付きあり(ETag・Last-Modified) | 304の回数17 / 20回 | 転送した本文17,775バイト |
| 条件付きなし | 304の回数0 / 20回 | 転送した本文124,295バイト |
| ETagが毎回変わる配信元(条件付きあり) | 304の回数0 / 20回 | 転送した本文124,295バイト |
条件付きありでは、200が返ったのは初回と更新直後の計3回だけで、残り17回は304でした。本文の転送量は、条件付きなしの約14%です。更新の頻度が高いフィードでは、この割合は下がります。304の比率は、巡回間隔とフィードの更新頻度で決まる値であって、定数ではありません。
ETagが毎回変わると効果はゼロになる
3行目は、更新がないのにETagだけが毎回違う値で返る配信元の再現です。こちらは etag= を送っても一致せず、20回すべてが200でした。転送量は条件付きなしと同じです。
実在のサーバーでも、これに近い状態が起こりえます。MDNは、コンテンツエンコーディングが変わるとETagも変える必要があるため、リバースプロキシなどが圧縮時にETagを書き換えるサーバーがあると述べています。ApacheはETagに圧縮方式名(-gzip)を付け足す例として挙がっています。ETagが取得のたびに揺れるなら、Last-Modified 側が効いているかを見てください。
「304の割合」は、この揺れを見つける指標にもなります。配信元が条件付きGETに対応しているはずなのに、割合が0%に張り付いているときは、ETagの安定性か、Last-Modified の有無を疑う順に調べます。
ETagが無くLast-Modifiedだけの配信元
ETagを返さず、Last-Modified だけを返す配信元も想定できます。同じ検証サーバーでETagの出力と判定を外し、Last-Modified だけで20回巡回した結果、304は同じく17回、転送量も17,775バイトでした。状態ファイルには "etag": null と modified の日時だけが残ります。
つまり、保存と送信を両方に対応させておけば、ETagが無い配信元でも効果は変わりません。逆に、etag だけを保存するスクリプトにすると、この種の配信元では毎回200になります。Claudeに書かせるときは「ETagが無い場合も modified で動くこと」を仕様に入れておくと、この取りこぼしを避けられます。
304のときは「0件」ではない
feedparserのドキュメントの例では、304のとき d2.entries は空のリスト、d2.feed は空の辞書です。これは「フィードが空になった」ではなく、「前回から変化なし」を意味します。
そのため、後段の処理で次の分岐を入れ忘れると、更新のない回ごとに「全記事が削除された」と誤解します。
if status == 304:
sys.exit(0) # 変化なし。何もしない
if status is None:
sys.exit(1) # 通信エラー。状態ファイルは触らない
# ここに来るのは 200 だけ。entries を処理する手元で到達不能なポートを指定した場合も、status は属性として存在せず、bozo が真になるだけでした。d.status を直接参照するスクリプトは、ここで例外になります。getattr で取る形にしておくと、巡回の失敗と304を別物として扱えます。
200が返った回だけClaudeを呼ぶ
定期実行の主役はスクリプトで、Claudeは新着があったときの要約や分類にだけ呼びます。304の回にClaudeを起動する理由はありません。headlessモードは claude -p で非対話実行でき、終了コードは成功が0、失敗が非0です。スクリプトの分岐に使えます。
#!/bin/sh
# cron から呼ぶ。新着があるときだけ claude -p を起動する
# --bare はサブスクリプションのログインを使わない。
# ANTHROPIC_API_KEY を cron 側の環境に設定しておくこと
out=$(.venv/bin/python poll.py "$FEED_URL" state.json cond) || exit 1
case "$out" in
200*) claude --bare -p "最新エントリを3行で要約して" \
--output-format json > summary.json ;;
*) : ;; # 304 なら何もしない
esac--bare は、スクリプトやSDK呼び出しで推奨される起動モードです。フックやCLAUDE.md、MCPサーバーの自動読み込みを省き、起動を速くします。
ただし --bare はclaude.aiのログインを一切使いません。認証は ANTHROPIC_API_KEY、--settings で渡すファイルの apiKeyHelper、またはクラウドプロバイダーの認証情報から取ります。サブスクリプションでログインしているだけのcron環境では認証に失敗し、summary.json は作られません。APIキーで動かす場合は従量課金になります。サブスクリプションの範囲で動かすなら、--bare を外して起動します。
--output-format json の応答には total_cost_usd が含まれ、APIキーでの呼び出しごとの費用を記録できます(クライアント側の見積もりで、実際の請求額とは差が出ることがあります)。
多重起動を防ぎたい場合は、cronに載せる前にロックを入れます。方法はcronの二重起動をflockで防ぐにまとめています。claude -p を使った非対話実行の基本はClaude Codeのヘッドレスモードにあります。
巡回間隔と304の割合の見方
条件付きGETを入れても、巡回の頻度を上げれば上げるほど配信元へのリクエスト数は増えます。リクエスト数を減らす手段ではなく、1リクエストあたりの転送量を減らす手段だと切り分けておくと、設計を誤りません。
| 状況 | 見る数字 | 取る手 |
|---|---|---|
| 304が大半 | 見る数字304の比率 | 取る手巡回間隔をさらに空けられるか検討 |
| 304が0% | 見る数字ETagの安定性、Last-Modified の有無 | 取る手curlで2回取って値を比べる |
| 200が毎回返るが中身は同じ | 見る数字エントリのIDや日付 | 取る手配信元が更新日時を毎回書き換えていないか |
| 通信エラーが続く | 見る数字status が None の回数 | 取る手間隔を延ばし、エラー時は状態を更新しない |
3行目の「200が毎回返るが中身は同じ」は、ETag以前の問題です。条件付きGETは配信元の協力を前提にしているので、協力が得られない相手にはエントリのIDで重複を除く別の層が要ります。
実在のフィードで使う前の確認
検証は自作のローカルサーバーで行ったため、実在の配信元で同じ比率が出るとは限りません。実際のフィードに向ける前に、次の3点を確かめてから巡回に載せます。
- 1回目の応答に
ETagかLast-Modifiedのどちらかがあるか(curlの-D -で見る) - 取得したETagを
If-None-Matchに付けて、304が返るか - 2回の取得で、更新がないのにETagが変わっていないか
この3つが通れば、巡回間隔は配信元の更新頻度に合わせて決められます。通らない配信元については、ドキュメントが言う「アクセス拒否」のリスクを避けるためにも、間隔を空けるのが現実的です。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。