環境
| 項目 | 内容 |
|---|---|
| OS | Ubuntu 22.04 (GitHub Actions の scheduled workflow) |
| Python | 3.11.6 |
| HTTPクライアント |
urllib.request(標準ライブラリ)+ http.cookiejar
|
| 検証用 |
requests 2.31.0、Chrome DevTools |
| 対象 | 公的機関が公開する名簿検索サイト(都道府県別の一覧、ページネーションあり) |
| 実行頻度 | 毎日 02:00 JST に差分取得 |
同じ構成で動かしている人は多いはずです。特別なライブラリは使っていません。だからこそ、この罠は静かに刺さります。
症状:1ページ目は取れる、2ページ目以降が0件
夜間バッチは次のような流れでした。
- 検索フォームにPOSTして1ページ目を取得
- レスポンスHTMLから「次へ」ボタン周辺の
input[type=hidden]を抜き出す - そのhidden値だけをPOSTして2ページ目を取得
- 空になるまで繰り返す
ところが2ページ目以降のレスポンスは、HTTPステータス200なのにレコード0件。例外も出ない、リトライも走らない、ログには 200 OK が並ぶだけ。しかも全都道府県で同じ症状でした。
気づいたときには、集まっていたのは本来の対象の1割強。1ページ目だけが毎回取れていたので、「それっぽい件数」が出てしまい、しばらく成功扱いのまま回っていました。これが一番怖いところです。失敗が失敗の形をしていない。
原因:hiddenだけ送っていた
ブラウザがフォームを送信するとき、送信対象は input[type=hidden] だけではありません。
-
selectの現在選択中のvalue -
radioのcheckedなもの -
checkboxのチェック済みのもの -
textareaの中身 - 送信ボタン自身の
name=value
つまり、都道府県や種別、並び順といった検索条件そのものがPOSTボディに含まれている。こちらはページ送りに必要なトークンだけを送っていたので、サーバー側では検索条件が成立せず、「条件に合致する結果は0件です」という正常なレスポンスが返っていたわけです。
HTTPステータスでエラーを表現していないアプリケーションでは、リクエストの不備と「結果が空」が同じ200に潰れます。ここを分けて考えられていなかったのが敗因でした。
修正手順
1. DevToolsでリクエストを丸ごと確認する
まず、推測をやめます。Chrome DevTools の Network タブを開き、Preserve log を有効にしてから実際にページ送りを操作し、該当リクエストを右クリック → Copy as cURL します。これをそのままターミナルに貼れば、必要なヘッダ・Cookie・ボディが全部見えます。
curl 'https://example.go.jp/roster/search' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'pref=13' \
--data-urlencode 'kind=1' \
--data-urlencode 'sort=name' \
--data-urlencode 'page=2' \
--data-urlencode 'csrf_token=xxxxx'
--data-urlencode の行が、そのまま「送るべきパラメータ一覧」です。
2. フォームの状態を辞書として保持する
ページ送りは「前のリクエストの条件を引き継ぐ」処理です。hiddenだけを引き継ぐのではなく、フォーム全体の状態を辞書で持つように変えました。
from html.parser import HTMLParser
class FormStateParser(HTMLParser):
"""フォームの現在値をそのまま辞書にする(hidden/select/radio/checkbox)"""
def __init__(self):
super().__init__()
self.state = {}
self._select_name = None
self._option_value = None
def handle_starttag(self, tag, attrs):
a = dict(attrs)
name = a.get("name")
if not name:
return
if tag == "input":
itype = (a.get("type") or "text").lower()
if itype == "hidden":
self.state[name] = a.get("value", "")
elif itype in ("radio", "checkbox"):
if "checked" in a: # チェック済みのみ送信される
self.state[name] = a.get("value", "")
else:
self.state.setdefault(name, a.get("value", ""))
elif tag == "select":
self._select_name = name
elif tag == "option" and self._select_name:
self._option_value = a.get("value", "")
if "selected" in a:
self.state[self._select_name] = self._option_value
def handle_endtag(self, tag):
if tag == "select":
# selected が1つも無ければ先頭optionが選択されている
self.state.setdefault(self._select_name, self._option_value or "")
self._select_name = None
3. セッション(Cookie・CSRF)を維持したままPOSTする
import logging
import time
from http.cookiejar import CookieJar
from urllib.parse import urlencode
from urllib.request import HTTPCookieProcessor, Request, build_opener
logger = logging.getLogger(__name__)
class RosterClient:
def __init__(self, base_url: str):
self.base_url = base_url
self.opener = build_opener(HTTPCookieProcessor(CookieJar()))
def post(self, params: dict) -> str:
body = urlencode(params, encoding="utf-8").encode("utf-8")
req = Request(self.base_url, data=body, method="POST")
req.add_header("Content-Type", "application/x-www-form-urlencoded")
req.add_header("User-Agent", "Mozilla/5.0 (compatible; RosterBot/1.0)")
with self.opener.open(req, timeout=30) as res:
html = res.read().decode("utf-8", errors="replace")
logger.info("post status=%s page=%s bytes=%d",
res.status, params.get("page"), len(html))
return html
def crawl(self, base_params: dict, max_page: int = 200):
state = dict(base_params)
for page in range(1, max_page + 1):
state["page"] = page
html = self.post(state)
rows = extract_rows(html) # 適宜パース
logger.info("page=%s rows=%d", page, rows)
if not rows:
break
time.sleep(1.2) # 相手サーバーへの配慮
yield rows
「200=成功」をやめる
修正と同時に、ステータスコードではなく件数で健全性を判定するようにしました。
MIN_ROWS = 1 # 1ページあたりの下限
EXPECTED_MIN_TOTAL = 5000
if len(rows) < MIN_ROWS:
logger.error("empty page detected: page=%s params=%s", page, state)
raise RuntimeError("0 rows with 200 OK — 条件欠落の可能性")
あわせて、総件数が想定下限を下回ったら失敗扱いにし、前回実行との差分も記録します。
- 総件数が前回比 -20% を超えて減ったら警告
- 0件ページが1回でも出たら、その時点のPOSTボディをログに残して異常終了
- 収集件数をメトリクスとして時系列で残す
これで「静かに1割しか取れていない」状態は、翌朝には検知できます。
比較表:送っていたもの vs 本来送るべきもの
| 項目 | hiddenだけ送る実装 | ブラウザの再現 |
|---|---|---|
| CSRFトークン | ○ 送っていた | ○ |
| ページ番号 | ○ | ○ |
| 都道府県(select) | × 欠落 | ○ |
| 種別(radio) | × 欠落 | ○ |
| 並び順(select) | × 欠落 | ○ |
| Cookie | △ 初回のみ | ○ セッション維持 |
| 結果 | 200 / 0件 | 200 / 期待件数 |
ハマりポイント
- 200は成功を意味しない。 アプリ都合のエラーを200で返す設計は珍しくない。
- 1ページ目成功は検証にならない。 初回リクエストは条件を自分で全部組み立てるので通ってしまう。ページ送りは条件の引き継ぎで壊れる。
-
selectのselected省略に注意。 未指定なら先頭optionが選択値になる。パーサ側でsetdefaultを忘れると落ちる。 -
checkboxは未チェックなら送信されない。 空文字を入れると挙動が変わるサイトもある。 -
urllibはリダイレクトもCookieも面倒。 素直にrequests.Sessionを使うほうが再現性が高い。標準ライブラリにこだわるならHTTPCookieProcessorを必ず挟む。 - robots.txt と利用規約、アクセス間隔の確保は前提として守る。ここを外すと技術以前の問題になる。
FAQ
Q. requests にすれば防げますか?
A. 防げません。問題はHTTPクライアントの選択ではなく、送信パラメータの再現漏れです。requests でもhiddenだけ送れば同じ結果になります。
Q. ステータス200以外も失敗扱いにすべきですか?
A. 200以外は失敗、で正しいですが、それだけでは不足です。200かつ期待件数を満たすを成功条件にしてください。
Q. ブラウザ自動化(Playwright等)に逃げるのはありですか?
A. 最終手段としてはありです。ただし実行コストと壊れやすさが上がるので、まずDevToolsで通信を丸ごと確認し、再現できるならHTTPクライアントで組むほうが運用は楽です。
Q. 何ページ目まで取れたかはどう記録しますか?
A. ページごとに「ページ番号・件数・POSTボディのハッシュ」をJSON Linesで出すのがおすすめです。ボディのハッシュが変わったのに件数が0になった瞬間が、まさに今回の異常です。
まとめ
やることはシンプルです。
- ページ送りを組む前に、ブラウザの通信内容を丸ごと確認する
- hiddenだけでなくフォーム全体の状態を引き継ぐ
- 200を成功と見なさず、件数で検知する
初回取得が成功しただけで確認を終えていませんか。ページ送りは「2ページ目」で必ず壊れます。そして壊れ方が静かなので、気づくのはいつも数週間後です。
この記事を書いた人
BENTEN Web Works — 業務自動化・システム開発のフリーランスエンジニアです。
GAS / Python / RPA を使った業務自動化や、Web制作・システム開発のご相談を承っています。
「こんなこと自動化できる?」というご質問だけでもお気軽にどうぞ。
👉 業務自動化サービス — 詳細・お問い合わせはこちら
🐦 X(旧Twitter) — 日々の知見を発信中