はじめに
平日の朝 9:10 に、生成AIまわりの新着を集めるジョブが launchd から動いている。Hugging Face のモデル、GitHub、PyPI、それに arXiv の論文。arXiv の分は export API に次のクエリを投げ、新しい順に6件を取っていた。
https://export.arxiv.org/api/query?search_query=(cat:cs.CV+OR+cat:cs.SD)+AND+(abs:diffusion+OR+abs:generation)&sortBy=submittedDate&sortOrder=descending&max_results=6
2026-09-14、自動ジョブの棚卸しでこのジョブのログを読み返した。ジョブは毎回 exit 0 で終わっている。ところが 9月の10回のうち7回、arXiv の行が arxiv: 0 だった。
09-01 arxiv: 6
09-02 arxiv: 0
09-03 arxiv: 0
09-04 arxiv: 0
09-07 arxiv: 0
09-08 arxiv: 6
09-09 arxiv: 0
09-10 arxiv: 6
09-11 arxiv: 0
09-14 arxiv: 0
取れなかったときの理由は2種類あった。HTTP Error 429 と、読み取りのタイムアウト(20秒)。
WARN fetch failed [http://export.arxiv.org/api/query?search_query=(cat:cs.CV+OR+cat:cs.SD)+AND+(abs]: The read operation timed out
収集の関数は失敗を WARN にとどめて空のリストを返す作りなので、ジョブ全体は「成功」になる。
待っても429
まず、待てば通るかを試した。https に変え、タイムアウトを60秒に延ばし、失敗したら30秒、さらに90秒待ってから叩き直す。
結果は3回とも429。ジョブが走る 9:10 とは別の 13時台に試しても同じだった。
User-Agent も変えた。独自の名前を付けても、curl の既定のままでも429。応答には Retry-After ヘッダーが無く、どれだけ待てばいいかをサーバーは返してこない。応答ヘッダーを見ると前段は Google Frontend と varnish で、制限はその手前でかかっているように見えた。
待てば通る作りではない、と判断した。叩き直すたびに制限を延ばしている可能性もあるので、試す回数はここで止めた。
RSS なら200
arXiv には export API とは別に、カテゴリごとの RSS がある。
curl -s -o /dev/null -w "%{http_code}\n" "https://rss.arxiv.org/rss/cs.CV"
# 200(144件)
429 が続いていたのと同じ時間帯に、200 で144件取れた。カテゴリは + でつなげば1回で取れる。
curl -s "https://rss.arxiv.org/rss/cs.CV+cs.SD" | grep -c "<item>"
# 159(うち cs.SD 17件)
選択肢を2つ出した。「1. arXiv を RSS に切り替える」「2. 再試行のまま様子を見る」。1 が選ばれた。
旧クエリを RSS で再現する
RSS と API は返すものが違う。RSS はその日に発表された分だけで、検索はできない。旧クエリの条件(要約に diffusion か generation を含む・新しい順・6件)は、受け取ったあとで絞って再現した。
RSS の各 <item> には <arxiv:announce_type> が入っていて、値は new / cross / replace / replace-cross の4種類。replace 系は既存論文の改訂なので、新着としては落とす。要約は <description> の中にある。
ARXIV_CATEGORIES = ["cs.CV", "cs.SD"]
ARXIV_MAX = 6
ARXIV_KEYWORDS = re.compile(r"diffusion|generation", re.I) # 旧 API クエリと同条件
def fetch_arxiv() -> list[dict]:
url = "https://rss.arxiv.org/rss/" + "+".join(ARXIV_CATEGORIES)
raw = fetch(url, timeout=60, accept="application/rss+xml")
items = []
for e in re.findall(r"<item>(.*?)</item>", raw, re.S):
tm = re.search(r"<title>(.*?)</title>", e, re.S)
im = re.search(r"<link>(.*?)</link>", e, re.S)
dm = re.search(r"<description>(.*?)</description>", e, re.S)
if not tm or not im:
continue
# replace / replace-cross は既存論文の改訂なので新着として扱わない
at = re.search(r"<arxiv:announce_type>(.*?)</arxiv:announce_type>", e)
if at and at.group(1).strip() not in ("new", "cross"):
continue
if not ARXIV_KEYWORDS.search(dm.group(1) if dm else ""):
continue
title = re.sub(r"\s+", " ", html.unescape(tm.group(1))).strip()
items.append({"title": title, "url": im.group(1).strip()})
return items[:ARXIV_MAX]
実際のコードは待機つきの再試行と、取得が0件だった日に WARN 収集元0件: arxiv を1行出す処理を残している。ここでは絞り込みの部分だけを載せた。タイトルには & のような実体参照が入るので html.unescape で戻している。
切り替えた日の確認はこう。
| 確かめたこと | 結果 |
|---|---|
| 本物の RSS から取る | 0.1秒で6件 |
| 保存した159件のサンプルで絞る | 上限の6件に収まり、replace の混入は0 |
| 取得を空にして通しで回す |
WARN 収集元0件: arxiv が出る |
切り替えたあとの4週間
翌日 09-15 から 10-09 までのログを並べた。
09-15 6 09-16 6 09-17 6 09-18 6
09-21 0 09-22 6 09-23 6 09-24 6 09-25 6
09-28 0 09-29 6 09-30 6 10-01 6 10-02 6
10-05 0 10-06 6 10-07 6 10-08 6 10-09 6
429 は1回も出ていない。そのかわり、0 が3回ある。09-21、09-28、10-05。3日とも月曜だった。
RSS の先頭には <skipDays> があり、土曜と日曜が入っている。arXiv は週末に発表しない。切り替えたときの記録にも「土日は発表が無い。平日のジョブなら問題ない」と書いていた。
月曜の朝に RSS を1回だけ取ってみた。
$ date
Mon Oct 12 05:58:44 JST 2026
$ curl -s "https://rss.arxiv.org/rss/cs.CV+cs.SD" | grep -c "<item>"
0
<lastBuildDate>Sun, 11 Oct 2026 20:58:44 +0000</lastBuildDate>
<pubDate>Sun, 11 Oct 2026 00:00:00 -0400</pubDate>
<skipDays>
<day>Sunday</day>
<day>Saturday</day>
</skipDays>
日本は月曜でも、RSS の日付はまだ日曜(米東部時間)だった。ジョブの 9:10 も、米東部では日曜の 20:10 にあたる。平日だけ動くジョブにしたのに、月曜の回は相手側の日曜を読んでいた。0件の警告はそのたびに出ていて、ログには3回とも WARN 収集元0件: arxiv が残っている。
10-12 の時点で、コードは月曜を特別扱いしていない。
待てば通ると思って、待ち時間を延ばしていた。止まっていた理由が向こうの制限なら、こちらがどれだけ待っても同じだった。曜日も同じで、こちらの平日が向こうの平日とは限らなかった。