0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「200 OKなのに0件」に3週間気づけなかった夜間バッチ収集を直した話

0
Posted at

環境

項目 内容
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件

夜間バッチは次のような流れでした。

  1. 検索フォームにPOSTして1ページ目を取得
  2. レスポンスHTMLから「次へ」ボタン周辺の input[type=hidden] を抜き出す
  3. そのhidden値だけをPOSTして2ページ目を取得
  4. 空になるまで繰り返す

ところが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になった瞬間が、まさに今回の異常です。

まとめ

やることはシンプルです。

  1. ページ送りを組む前に、ブラウザの通信内容を丸ごと確認する
  2. hiddenだけでなくフォーム全体の状態を引き継ぐ
  3. 200を成功と見なさず、件数で検知する

初回取得が成功しただけで確認を終えていませんか。ページ送りは「2ページ目」で必ず壊れます。そして壊れ方が静かなので、気づくのはいつも数週間後です。


この記事を書いた人

BENTEN Web Works — 業務自動化・システム開発のフリーランスエンジニアです。

GAS / Python / RPA を使った業務自動化や、Web制作・システム開発のご相談を承っています。
「こんなこと自動化できる?」というご質問だけでもお気軽にどうぞ。

👉 業務自動化サービス — 詳細・お問い合わせはこちら
🐦 X(旧Twitter) — 日々の知見を発信中

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?