スクレイピングで集めた商品一覧をpandasで加工し、条件に合う行へメモを書き込む。何年も動いていたこの処理が、pandas 3.0に上がってからエラーも出さずに何も書き込まなくなった。そんな経験はないでしょうか。原因はCopy-on-Write(コピーオンライト、以下CoW)が既定の挙動になり、df[条件]["列"] = 値 という連鎖代入が元のDataFrameに届かなくなったことです。この記事では、pandas 3.0での今の書き方である .loc[行条件, "列"] と assign() を、動くスクレイピングのコードと一緒に説明します。あわせて、systemd timerで毎日回すスクリプトがpandasの更新で静かに壊れないよう、版を固定する方法までまとめます。

pandas 3.0のCopy-on-Writeで連鎖代入 df[条件]["列"] = 値 が効かなくなった理由

以前はこう書いていた

価格が0より大きい行の note 列に「在庫あり」と書く処理を、以前はこう書く人が多かったはずです。

# 以前の書き方(pandas 3.0 では元の df に反映されない)
df[df["price"] > 0]["note"] = "在庫あり"

この1行は、内部で2段階に分かれています。

  1. df[df["price"] > 0] で条件に合う行を取り出す(ここで一時的なDataFrameができる)
  2. その一時的なDataFrameの note 列に値を入れる

pandas 2系までは、1で取り出したものが元の df とメモリを共有している場合があり、そのときだけ書き込みが元に届いていました。届くかどうかがデータの形次第だったので、SettingWithCopyWarning という警告で注意を促していたわけです。

CoWが既定になって何が変わったか

CoWは「取り出した結果は常に元とは別物として扱い、書き換えが起きたときに初めて実体をコピーする」という仕組みです。pandas 3.0ではこれが既定になったため、1で取り出した一時的なDataFrameへの書き込みは必ず一時的なものの側だけで完結し、元の df は変わりません。

厄介なのは、これが例外で止まらないことです。警告が出ても処理は最後まで進み、note 列が空のままCSVが出力されます。手元で対話的に動かしていれば気づけても、サーバーの定期実行ではログに埋もれます。

同じ理由で効かなくなる書き方

以前の書き方

pandas 3.0で起きること

今の書き方

df[df["price"] > 0]["note"] = "在庫あり"

元の df は変わらない

df.loc[df["price"] > 0, "note"] = "在庫あり"

df["price"][0] = 100

元の df は変わらない

df.loc[0, "price"] = 100

df["note"].fillna("", inplace=True)

列を取り出したものだけが埋まる

df["note"] = df["note"].fillna("")

3行目の inplace=True は見落としやすいところです。df["note"] で列を取り出した時点で別物になるので、その場で書き換えても元には届きません。結果を代入し直す形にします。

スクレイピング結果の加工を .loc と assign() で書き直す(動くコード)

実行環境

  • Python 3系(python3 -V で確認。pandas 3系が対応するPythonのバージョンは公式ドキュメントのインストールの項で確認してください)
  • pip install requests beautifulsoup4 pandas
  • 入った版は pip show pandas で確認できます。Version: が3で始まっていればこの記事の挙動になります

取得先は https://example.com/products という架空の一覧ページで、li.product の中に .name と .price がある構造を想定しています。実際のサイトに合わせてセレクタを置き換えてください。

コード全体

import sys
import time

import pandas as pd
import requests
from bs4 import BeautifulSoup

BASE_URL = "https://example.com/products?page={}"
HEADERS = {"User-Agent": "price-check/1.0 (+https://example.com/about)"}
WAIT_SECONDS = 5


def fetch_rows(pages=3):
    rows = []
    with requests.Session() as session:
        session.headers.update(HEADERS)
        for page in range(1, pages + 1):
            res = session.get(BASE_URL.format(page), timeout=30)
            res.raise_for_status()
            soup = BeautifulSoup(res.text, "html.parser")
            for item in soup.select("li.product"):
                name = item.select_one(".name")
                price = item.select_one(".price")
                rows.append({
                    "name": name.get_text(strip=True) if name else "",
                    "price_text": price.get_text(strip=True) if price else "",
                })
            time.sleep(WAIT_SECONDS)
    return rows


def clean(rows):
    df = pd.DataFrame(rows)
    df = df.assign(
        price=lambda d: pd.to_numeric(
            d["price_text"].str.replace(r"[^\d]", "", regex=True),
            errors="coerce",
        ),
        note="",
    )
    df.loc[df["price"] > 0, "note"] = "在庫あり"
    df.loc[df["price"].isna(), "note"] = "価格取得失敗"
    return df


def check(df):
    if df.empty:
        sys.exit("1件も取得できていない。セレクタかページ構造を確認")
    if (df["note"] == "").all():
        sys.exit("note が1件も埋まっていない。加工が反映されていない可能性")


if __name__ == "__main__":
    df = clean(fetch_rows())
    check(df)
    df.to_csv("products.csv", index=False, encoding="utf-8-sig")
    print(f"{len(df)}件を書き出しました")

fetch_rows():requests.Session で一覧を集める

requests.Session を使い、User-Agentなどの共通ヘッダーを1度だけ設定して全ページで使い回しています。raise_for_status() は、404や500が返ったときにその場で例外にするためのものです。ここで止めないと、エラーページを空の一覧として読み、0件のCSVが「正常に」できあがります。要素が見つからない場合に備えて select_one() の結果を if name else "" で受けているのは、1件の欠けで全体が落ちないようにするためです。ページごとに time.sleep(WAIT_SECONDS) で間を空けています。

clean():assign() で列を作り、.loc で条件付きに書き込む

assign() は「新しい列を足したDataFrameを返す」メソッドです。元を書き換えずに結果を返すので、CoWと相性がよく、df = df.assign(...) と代入し直す形で使います。price=lambda d: ... の d は、その時点のDataFrameを指します。ここでは「¥1,980」のような文字列から数字以外を取り除き、pd.to_numeric(..., errors="coerce") で数値にしています。errors="coerce" は、数値にできないものを例外にせず欠損値(NaN)にする指定です。

そのあとの2行が今回の主役です。df.loc[行条件, "列"] = 値 は「df の、この条件の行の、この列に書く」を1回の操作で表すので、途中に一時的なDataFrameが挟まりません。そのため pandas 3.0 でも確実に元の df に反映されます。

check():反映されたかを件数で確かめる

連鎖代入の問題は「止まらずに結果が空になる」ことでした。そこで、note 列が全部空なら sys.exit() で異常終了させています。sys.exit() に文字列を渡すと、標準エラーにその文を出して終了コード1で終わります。systemd はこれを失敗として記録するので、後述の journalctl や systemctl --user status で見つけられます。加工の成否を例外ではなく結果の中身で確かめるのが、CoW以降の書き方の要点です。

絞り込んだ側だけを変えたいとき

逆に、元の df は変えずに一部だけ取り出して加工したいなら、変数に受けてから書けば済みます。

in_stock = df[df["price"] > 0]
in_stock["note"] = "要発注チェック"   # in_stock だけが変わり、df は変わらない

以前はここで警告を消すために .copy() を付けるのが定番でしたが、CoWでは取り出した時点で別物として扱われるため、意図がはっきりしていれば付けなくても動きます。付けても害はないので、既存コードから急いで消す必要はありません。

手元の古いコードから連鎖代入を探す

既存のスクリプトに残っている箇所は、次の2つの検索でおおむね拾えます。

grep -nE '\]\[[^]]+\] *=' *.py
grep -n 'inplace=True' *.py

1つ目は ][...] = の形(角括弧が2つ続いた直後に代入)を探します。2つ目は前述の inplace=True です。どちらも誤検出はありますが、見るべき行が数十行に絞れれば目で確認できます。日付の扱いも同時期に変わっているので、移行のついでに pandas date_format移行とAI指示|26年10月 も確認しておくと、一度の改修で済みます。

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

加工の書き方を直しても、取得の仕方が相手に迷惑をかけていれば続けられません。毎日動かす前に次を確認してください。

  • 公式APIがあるならそちらを使う。HTMLの構造変更で壊れる心配が減り、相手のサーバーにも優しい方法です
  • robots.txt を確認する。https://example.com/robots.txt で、取得したいパスが Disallow になっていないかを見ます
  • 利用規約を読む。自動取得を禁じているサイトは対象にしません。収集したデータの再配布は著作権法上の複製権(第21条)・公衆送信権(第23条)に触れうるので、社内での集計にとどめるのが基本です
  • アクセス間隔を空ける。上のコードでは1ページごとに5秒待っています。並列で一気に取りにいく書き方はしません
  • User-Agentで素性を示す。ブラウザのふりをするのではなく、何のためのプログラムかが分かる名前にしておくと、相手が問題に気づいたときに連絡がつきます

2026年現在、Cloudflareなどのボット対策で403が返るサイトが増えていますが、それは「自動取得を望んでいない」という意思表示でもあります。403が返ったら回避策を探すのではなく、そのサイトを対象から外すか、公式の手段を探してください。

systemd timerで毎日回すためにpandasの版を固定する

なぜ版の固定が要るのか

pandas 2系から3系への移行のように、メジャーバージョンが上がると「同じコードで結果が変わる」ことが起きます。サーバー上で何かのついでに pip install -U を実行すると、その日から加工が反映されなくなる、ということが現実に起こりえます。手元で動作を確認した版を、そのままサーバーに再現できるようにしておくのが目的です。

これはpandasだけの話ではありません。調査時点(2026年10月)で、Seleniumは2026年のリリースすべてでPython 3.10以上が必須になっており、Playwrightは1.63.0(2026年9月4日リリース)でUbuntu 26.04のサポートを加えています。Python本体やOSの更新とライブラリの対応状況は連動して動くので、まとめて固定しておくと安全です。

仮想環境を作って版を書き出す

cd /home/username/price-check
python3 -m venv .venv
.venv/bin/pip install requests beautifulsoup4 pandas
.venv/bin/python main.py          # 手元で動作を確認
.venv/bin/pip freeze > requirements.txt

pip freeze は、仮想環境に入っているパッケージを パッケージ名==版 の形で書き出すコマンドです。こうしてできた requirements.txt をサーバーに持っていき、次のように入れれば、手元と同じ版がそろいます。

cd /home/username/price-check
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt

版を上げたいときは、手元で上げて main.py を動かし、check() を通ることを確かめてから requirements.txt を作り直します。サーバー側で直接上げないのが運用上の約束になります。

service と timer を書く

ユーザー権限で動かす場合、ユニットファイルは ~/.config/systemd/user/ に置きます。

# /home/username/.config/systemd/user/price-check.service
[Unit]
Description=price check scraper

[Service]
Type=oneshot
WorkingDirectory=/home/username/price-check
ExecStart=/home/username/price-check/.venv/bin/python main.py
# /home/username/.config/systemd/user/price-check.timer
[Unit]
Description=run price check daily

[Timer]
OnCalendar=*-*-* 07:30:00
Persistent=true

[Install]
WantedBy=timers.target
  • ExecStart には仮想環境のPythonを絶対パスで書きます。python3 とだけ書くと、システムに入っている別の版のpandasで動いてしまい、固定した意味がなくなります
  • WorkingDirectory は、products.csv の出力先を決めるために指定しています。定期実行ではログイン時と作業ディレクトリや環境変数が違うので、相対パスに頼らないようにします
  • Persistent=true は、マシンが止まっていて時刻を逃した場合に、起動後に1回だけまとめて実行する指定です

有効化と確認は次のとおりです。

systemctl --user daemon-reload
systemctl --user enable --now price-check.timer
loginctl enable-linger username      # ログアウトしても user timer を動かし続ける
systemctl --user list-timers         # 次回の実行時刻を確認
journalctl --user -u price-check.service   # 実行ログ(check() の失敗理由もここに出る)

調査では、cron・systemd timer・launchdのいずれにも2026年10月時点で大きな仕様変更は確認されていません。変わりやすいのはスケジューラではなくPythonとライブラリの側なので、版の固定に力を割くのが効率的です。GitHub上で回す方法は GitHub Actions cronスクレイピング|26年10月 にまとめています。

どこで動かし続けるか:止まる条件で選ぶ

実行場所

向いている条件

止まる条件

手元のPC(launchd・systemd timer)

まず試したい、1日1回で十分

スリープ中・電源オフ中は動かない。ノートPCを閉じている日は丸ごと飛ぶ(Persistent=true 等で起動後に追いつく設定は可能だが、時刻通りにはならない)

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

requests+BeautifulSoupだけで済み、数分で終わる処理

実行時間・メモリの上限に当たると途中で打ち切られる。ブラウザを起動できない環境ではSelenium・Playwrightが使えない。仮想環境やpipの利用可否はサーバーごとに確認が要る

VPS(systemd timer)

ブラウザ自動化が要る、毎日確実に回したい

OSの更新や再起動、ディスクの空き不足は自分で面倒を見る必要がある。止まっても誰も教えてくれないので、check() の失敗を通知する仕組みを足すとよい(通知は LINE Notify代替をAIで書き直す|26年10月 を参照)

今回のコードはrequestsとBeautifulSoupだけなので、共有サーバーのcronでも動く可能性があります。ただしpandas 3系を入れられるかはサーバーのPythonの版次第です。python3 -V で確認し、入れられなければVPSか手元のPCを選んでください。

関連する選択肢

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

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

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

pandas 3.0の連鎖代入対策と定期実行のポイント

pandas 3.0ではCopy-on-Writeが既定になり、df[条件]["列"] = 値 のような連鎖代入は元のDataFrameに反映されなくなりました。しかも例外では止まらないため、定期実行では「空の列のままCSVが出続ける」形で現れます。今は df.loc[行条件, "列"] = 値 で1回の操作として書き、新しい列は df = df.assign(...) で作って代入し直すのが基本です。inplace=True を列に対して使っている箇所も同じ理由で見直してください。加えて、加工結果の中身を件数で確かめて異常終了させる関門を置くと、systemd timerの失敗として気づけます。サーバーでは仮想環境と pip freeze の requirements.txt で版を固定し、ExecStart に仮想環境のPythonを絶対パスで書くこと。版を上げるのは、手元で動作を確かめてからにします。