スクレイピングで集めた表をpandasで整形するスクリプトが、ある日から文字列列の判定をすり抜けるようになったかもしれません。原因はpandas 3.0の変更です。文字列列の既定のdtypeが object から str(StringDtype)に変わりました。そのため dtype == object の判定が外れ、astype(str) と fillna("") の組み合わせも以前と違う結果になります。この記事では、pandas 2.x と 3.x のどちらでも同じ結果になる書き方を示します。あわせて、cronやsystemd timerで毎日動かすスクリプトを pip の更新に巻き込ませないために、どこでバージョンを固定するかを説明します。

pandas 3.0で文字列列のdtypeが object から str に変わった:スクレイピング結果で起きる4つの違い

requestsとBeautifulSoupで集めた値を辞書のリストにして pd.DataFrame() に渡すと、pandas 2.x では文字列の列が object になっていました。object は「Pythonのオブジェクトなら何でも入る箱」です。pandas 3.0では、文字列の列は専用の str 型(StringDtype、文字列と欠損値だけを入れられる型)として作られます。

まず自分の環境のpandasのバージョンを確認する

どちらの挙動になるかは、インストールされているpandasのバージョンで決まります。推測せずに確認してください。

python3 -V
pip show pandas
python3 -c "import pandas as pd; print(pd.__version__, pd.DataFrame({'a': ['x', None]})['a'].dtype)"

最後の行の出力が object なら旧来の挙動で、str なら3.0の挙動です。対応しているPythonのバージョンはpandasのリリースごとに変わります。そのため、記事で特定の数値を挙げるのはやめておきます。pip show pandas の Requires-Python 相当の情報と、公式ドキュメントのリリースノートを確認してください。

スクレイピングのコードで影響が出る箇所

書き方

pandas 2.x(object)

pandas 3.0(str)

困ること

df["name"].dtype == object

True

False

「文字列列だけ前後の空白を削る」処理が丸ごと飛ばされる。エラーにはならない

df["name"].astype(str) の欠損値

"nan"・"None" という文字列になる

欠損値のまま残る

.replace("nan", "") で消していた古い対策が効かなくなる

文字列列に数値を代入

そのまま入る

型エラーになる

「取れなかったら0を入れる」書き方が落ちる

print(df.dtypes)

object

str

ログや検証用の比較が一致しなくなる

特に厄介なのが1行目です。例外が出ないので、空白や改行が残ったままのCSVが毎日黙って出力され続けます。細かい挙動は、公式ドキュメントの移行ガイドで自分の使うメソッドについて確認してください。下のコードは、上の4点のどちらに転んでも同じ結果になるように書いてあります。

pandasのメジャーバージョンが上がって既存のコードが動かなくなる例は、今回が初めてではありません。削除済みのメソッドを書き換える話はDataFrame.append削除をAIで直す|26年10月でも扱っています。

dtype == object の判定をやめる:pandas 2.x と 3.0 の両方で動く収集・整形コード

実行環境

  • Python:python3 -V で確認し、インストールするpandasが対応しているバージョンを使う
  • パッケージ:pip install requests beautifulsoup4 pandas
  • BeautifulSoup:PyPIの最新版は 4.15.0(2026年6月7日公開、Python 3.7以上)です。パーサーには追加インストールが不要な標準の html.parser を使います
  • インストールは必ずプロジェクト専用の仮想環境(venv)の中で行います。理由は後半の定期実行の節で説明します

全体のコード

架空の商品一覧ページ(https://example.com/products?page=1 から順に3ページ)から、商品名と価格を集めてCSVにします。

import time
import urllib.robotparser

import pandas as pd
import requests
from bs4 import BeautifulSoup
from pandas.api.types import is_object_dtype, is_string_dtype

BASE = "https://example.com"
LIST_URL = BASE + "/products?page={}"
UA = "my-collector/1.0 (+https://example.com/contact)"
OUT = "/home/username/scraper/items.csv"


def fetch_rows(pages=3):
    rows = []
    with requests.Session() as s:
        s.headers["User-Agent"] = UA

        rp = urllib.robotparser.RobotFileParser()
        rp.parse(s.get(BASE + "/robots.txt", timeout=10).text.splitlines())

        for page in range(1, pages + 1):
            url = LIST_URL.format(page)
            if not rp.can_fetch(UA, url):
                print("robots.txt で禁止されているので中止:", url)
                break

            res = s.get(url, timeout=10)
            res.raise_for_status()
            soup = BeautifulSoup(res.text, "html.parser")

            for card in soup.select("div.item"):
                name = card.select_one(".name")
                price = card.select_one(".price")
                rows.append({
                    "name": name.get_text(strip=True) if name else None,
                    "price": price.get_text(strip=True) if price else None,
                    "page": page,
                })
            time.sleep(3)
    return rows


def text_columns(df):
    """pandas 2.x の object 列と 3.0 の str 列を、どちらも「文字列の列」として拾う"""
    cols = []
    for col in df.columns:
        s = df[col]
        if is_object_dtype(s.dtype):
            values = s.dropna()
            if len(values) and values.map(lambda v: isinstance(v, str)).all():
                cols.append(col)
        elif is_string_dtype(s.dtype):
            cols.append(col)
    return cols


def clean(df):
    for col in text_columns(df):
        df[col] = df[col].fillna("").astype(str).str.strip()

    digits = df["price"].fillna("").astype(str).str.replace(r"[^0-9]", "", regex=True)
    df["price"] = pd.to_numeric(digits, errors="coerce").astype("Int64")
    return df


def main():
    df = pd.DataFrame(fetch_rows())
    print("pandas", pd.__version__, {c: str(t) for c, t in df.dtypes.items()})
    if df.empty:
        print("取得0件")
        return
    clean(df).to_csv(OUT, index=False, encoding="utf-8-sig")


if __name__ == "__main__":
    main()

ブロックごとの説明

  1. fetch_rows() の前半(robots.txt の読み込み):Sessionに識別用のUser-Agentを設定し、同じSessionで robots.txt を取得して RobotFileParser に渡しています。巡回するURLごとに can_fetch() で許可を確認し、禁止されていればその場で止めます。
  2. fetch_rows() の後半(抽出):select() で商品カードを取り出し、select_one() で名前と価格を取ります。要素が無いカードは None を入れます。ここで "" や 0 で埋めないのがポイントです。「取れなかった」という情報を欠損値として残し、整形はpandas側でまとめて行います。1ページごとに time.sleep(3) を入れて間隔を空けます。
  3. text_columns():dtype == object の代わりに使う判定です。pandas 3.0の str 列は is_string_dtype() で拾います。pandas 2.x の object 列は中身を見て、欠損値以外がすべて文字列のときだけ文字列列とみなします。object の列には数値や辞書が混ざることもあるので、型名だけで決めないようにしています。object の判定を先に書いているのは、is_string_dtype() が object 型にも真を返すことがあるためです。
  4. clean() の文字列処理:fillna("") → astype(str) → .str.strip() の順に並べています。この順番が後述の "nan" 問題を避ける要です。
  5. clean() の価格処理:「¥1,280」「1,280円」などから数字以外を取り除き、pd.to_numeric(..., errors="coerce") で数値にします。空文字は欠損値になります。最後の Int64(大文字のI)は欠損値を持てる整数型で、価格が取れなかった行でも小数の 1280.0 になりません。
  6. main():実行のたびに、pandasのバージョンと各列のdtypeをログに出します。サーバー側でいつの間にかpandasが入れ替わっても、ログを見れば object から str に変わった日が分かります。Excelで開く前提なら、文字化けを避けるため utf-8-sig で書き出します。

fillna("") と astype(str) は「fillna が先」に並べる

以前のコードでよく見かけたのが、次の書き方です。

# 以前の書き方(pandas 2.x 前提)
df["name"] = df["name"].astype(str).replace({"nan": "", "None": ""})

pandas 2.x では、astype(str) が欠損値を "nan" や "None" という文字列に変えていました。その後で fillna("") を書いても欠損値はもう残っていないので、何も起きません。そこで、文字列になった "nan" を置換で消すという回りくどい対策が広まりました。

pandas 3.0では、astype(str) の後も欠損値は欠損値のまま残ります。すると "nan" という文字列はどこにも存在しないので、置換は空振りします。後続の df["name"] == "" で「名前が取れなかった件数」を数えていた場合、その件数がある日から0になります。

# 今の書き方(2.x / 3.0 どちらでも同じ結果)
df["name"] = df["name"].fillna("").astype(str).str.strip()

先に欠損値を空文字で埋めれば、その後の astype(str) がどちらのバージョンでも同じ文字列を返します。この1行の並びを変えるだけで、バージョンの違いを吸収できます。違いを手元で確かめたいときは、次の2行をpandas 2.x の環境と3.0の環境でそれぞれ実行してください。

python3 -c "import pandas as pd; print(pd.Series(['a', None]).astype(str).tolist())"
python3 -c "import pandas as pd; print(pd.Series(['a', None]).fillna('').astype(str).tolist())"

文字列列に数値を入れない

「価格が取れなかったら0を入れる」という処理を、文字列の列に直接 df.loc[i, "price"] = 0 と書いている場合も注意が必要です。str 型は文字列と欠損値しか受け付けないので、ここで止まります。上のコードのように、まず欠損値のまま集め、数値に変換してから埋める順序に揃えてください。数値化した後なら df["price"].fillna(0) で問題なく埋められます。

取得先サイトへの配慮:robots.txt・利用規約・アクセス間隔

毎日自動で動かすスクリプトは、人が手で開くよりはるかに多く相手のサーバーにアクセスします。次の点はコードに組み込むか、運用前に確認してください。

  • robots.txt:上のコードのように can_fetch() で確認し、禁止されているパスは取りに行きません。robots.txt は変更されることがあるので、毎回の実行時に読み直します
  • 利用規約:robots.txt で許可されていても、利用規約で自動取得や取得データの再利用を禁じているサイトがあります。規約で禁止されている場合は取得しません。公式のAPIやデータ提供の仕組みがあれば、そちらを優先します
  • アクセス間隔:1リクエストごとに数秒の待機を入れ、並列化はしません。データの更新が1日1回なら、取得も1日1回で十分です。取得したHTMLを保存しておき、整形をやり直すときは再取得しないようにすると、相手へのアクセスも減らせます
  • 識別できるUser-Agent:問い合わせ先を含む名前を付けておけば、相手がアクセス元を判断しやすくなります
  • 制限への向き合い方:ログインが必要なページ、ボット対策でブロックされるページは「取得してほしくない」という意思表示です。回避する方法は探さず、取得対象から外してください
  • 取得したデータの扱い:個人情報や著作物を含む場合は、保存・公開の範囲に注意が必要です

cron・systemd timerで動かし続けるなら pandas は venv と requirements.txt で固定する

pandas 3.0の変更で一番困るのは、コードを1行も変えていないのに、ある日から結果が変わることです。よくある原因は2つあります。ひとつはシステム全体のPythonにpandasを入れていて、OSの更新や別の作業の pip install -U で一緒に上がってしまうこと。もうひとつは、定期実行のスクリプト自体に pip install --upgrade を書いてしまっていることです。

固定する場所は「定期実行が使う venv」

cd /home/username/scraper
python3 -m venv .venv
.venv/bin/pip install requests beautifulsoup4 pandas
.venv/bin/python collect.py          # 手で1回動かし、CSVを目で確認する
.venv/bin/pip freeze > requirements.txt

pip freeze は、その時点で入っている全パッケージを pandas==… のように完全一致の形で書き出します。pandasが内部で使う依存パッケージのバージョンも一緒に記録されるのが利点です。手で pandas>=2 のような範囲指定だけを書くと、次に環境を作り直したときに3.0が入ります。サーバーを移すときも、.venv/bin/pip install -r requirements.txt で同じ組み合わせを再現できます。

最近のUbuntuやDebianでは、システムのPythonに直接 pip install すると拒否されます。この件と venv の作り方はexternally-managedはvenvで|26年10月にまとめています。

定期実行は venv の python を絶対パスで呼ぶ

ここで python3 と書くと、システムのPython(つまり固定していないpandas)が使われます。必ず .venv/bin/python の絶対パスを書いてください。これだけで、他の作業で何を更新しても定期実行には影響しません。

systemd timer(ユーザー単位)の場合:

# /home/username/.config/systemd/user/scraper.service
[Unit]
Description=collect items

[Service]
Type=oneshot
WorkingDirectory=/home/username/scraper
ExecStart=/home/username/scraper/.venv/bin/python collect.py

# /home/username/.config/systemd/user/scraper.timer
[Unit]
Description=collect items daily

[Timer]
OnCalendar=*-*-* 06:00:00
Persistent=true

[Install]
WantedBy=timers.target
systemctl --user daemon-reload
systemctl --user enable --now scraper.timer
loginctl enable-linger username      # ログアウト中も動かす
journalctl --user -u scraper.service  # 先頭行の「pandas x.y.z {...}」でバージョンを確認

Persistent=true は、電源が切れていて実行時刻を逃した分を、次の起動時に実行する設定です。cron の場合も考え方は同じです。

0 6 * * * cd /home/username/scraper && .venv/bin/python collect.py >> collect.log 2>&1

pandas 3.0 へ上げるときは別の venv で並走させる

  1. python3 -m venv .venv-new を作り、requirements.txt から pandas の行だけ外して入れる。その後、新しいpandasを入れる
  2. 出力先を items_new.csv に変えて同じスクリプトを1回動かす(取得先へのアクセスを増やさないよう、保存済みHTMLから整形し直せる形にしておくとよい)
  3. 旧CSVと行数・空欄の数・価格の合計を比べる。text_columns() と fillna を先に置く書き方なら一致するはず
  4. 一致したら .venv と入れ替え、pip freeze > requirements.txt で固定し直す

どこで動かすか:止まる条件で選ぶ

実行場所

向いている条件

止まる条件

手元のPC(cron・systemd timer・launchd)

1日1回・数分で終わり、取りこぼしても翌日でよい

スリープ・シャットダウン中は動かない。電源を入れたときにまとめて動くかどうかは仕組みと設定次第。OSの更新でPython本体が入れ替わると venv の作り直しが要る

共有レンタルサーバーのcron

requests+BeautifulSoup+pandasで完結し、ブラウザを使わない

実行時間・メモリ・プロセス数に上限がある(数値は各社の仕様で確認)。Pythonのバージョンを選べない、venvを作れない、SeleniumやPlaywrightのブラウザを起動できない場合がある

VPS

Selenium・Playwrightでブラウザを使う、毎日確実に動かしたい、複数のジョブを抱える

OSの更新・再起動・ディスク容量不足・契約の失効は自分で管理する。pandasは venv で固定できるが、OSのアップグレードでPython本体が上がると venv が壊れる

どこで動かす場合も、ログの先頭にpandasのバージョンとdtypeを出しておくのが有効です。結果が変わった日に、コードの問題かライブラリの更新かをすぐに切り分けられます。

関連する選択肢

毎日決まった時刻にスクリプトを動かすだけなら、共有のレンタルサーバーでも足ります。cronが使えるプランを選べば、自分でOSを管理する必要はありません。

レンタルサーバー エックスサーバー

※ 広告を含みます(A8.net)。リンク経由でお申し込みがあった場合、手数料を受け取ることがあります。

pandas 3.0移行で書き換える3点と、バージョンを固定する場所

pandas 3.0では、スクレイピング結果の文字列列が object ではなく str 型で作られます。そのため、dtype == object による文字列列の判定、astype(str) の後の "nan" の置換、文字列列への数値の代入の3か所が影響を受けます。前の2つはエラーにならずに結果だけが変わるので、気づくのが遅れがちです。書き換え方は次の3つです。判定は is_object_dtype と is_string_dtype の組み合わせにする。整形は fillna("") を astype(str) より先に置く。数値は変換してから埋める。こうすれば2.xと3.0で同じ結果になります。毎日動かすスクリプトは専用の venv に入れて pip freeze で固定し、cronやsystemd timerからは .venv/bin/python を絶対パスで呼んでください。更新は別の venv で並走させ、出力を比べてから切り替えます。