スクレイピング結果を pandas で整形していたスクリプトが、エラーも出さずに「加工したはずの列が元のまま」のCSVを吐くようになった。原因はたいてい pandas 3.0 で既定になった Copy-on-Write(コピー・オン・ライト:取り出した部分を書き換えても元の表は変わらない仕組み)です。df["列"][条件] = 値 という連鎖代入は、もう元の DataFrame に届きません。この記事では、今は .loc と df.assign() でどう書くかを動くコードで示し、サーバーの venv で pandas>=3 を固定して systemd timer で毎日動かし続けるまでを説明します。
pandas 3.0 の Copy-on-Write で df["列"][条件] = 値 が反映されない理由
以前はこう書いていた
スクレイピングで取った価格や在庫の文字列を整形するとき、次のような書き方をよく見かけます。pandas 2 系までは多くの場合たまたま元の表に反映されていたため、そのまま使われ続けてきたコードです。
df["band"] = "通常"
df["band"][df["price"] >= 10000] = "高価格帯" # pandas 3.0 では df に反映されない
df[df["price"].isna()]["band"] = "価格不明" # これも反映されない
df["note"].fillna("", inplace=True) # inplace でも df は変わらない
なぜ効かなくなったのか
Copy-on-Write では、df["band"] や df[条件] のようにインデックスで取り出した結果は、常に「元とは別物」として振る舞います。上のコードはどれも「いったん取り出した一時的なオブジェクト」に代入しているだけで、df 本体には何も書き込まれません。fillna(..., inplace=True) も同じで、書き換わるのは取り出した側です。
厄介なのは、例外で止まらないことです。pandas はこの形を検知すると ChainedAssignmentError という名前の警告を出しますが、名前に反して例外ではなく警告なので、スクリプトは最後まで走って終了コード0で終わります。systemd timer や cron で回していると警告はログに埋もれ、「毎日正常終了しているのに、CSVの区分列がずっと空」という状態になります。
手元で一度 python -W error collect.py のように警告を例外に格上げして実行すると、こうした箇所を洗い出せます。
書き換えの対応表
以前の書き方 | 今の書き方 |
|---|---|
|
|
|
|
|
|
列を1つずつ作って上書きしていく |
|
原則は1つだけです。行と列を同時に指定して書き込むなら .loc[行, 列]、列を作り直すなら「代入し直す」か assign() で新しい DataFrame を受け取る。取り出したものに書き込む、という形を作らないことです。
今の書き方:requests + BeautifulSoup の取得結果を .loc と df.assign() で加工する
実行環境
- Python:
python3 -Vで確認してください。pandas 3 が対応していない古い Python ではpip install "pandas>=3"が該当バージョンを見つけられずに失敗します。対応範囲は PyPI の pandas のページで確認できます - パッケージ:
pip install "pandas>=3,<4" requests beautifulsoup4 - pandas を
>=3にする理由:このコードは 3.0 の挙動(Copy-on-Write と後述のstr型)を前提に書いているため、2 系で動くと型の判定結果が変わります。<4は、次のメジャー更新で同じような挙動の変化を黙って受けないための上限です - requests は 2.32.0 以降で
get_connectionが非推奨になっていますが、下のコードはSession.get()しか使わないので影響しません
取得先は https://example.com/products?page=N に div.item が並んでいる架空のページです。セレクタは実際のサイトに合わせて書き換えてください。
コード全体(collect.py)
import sys
import time
from datetime import datetime
from pathlib import Path
from urllib import robotparser
from zoneinfo import ZoneInfo
import pandas as pd
import requests
from bs4 import BeautifulSoup
BASE_URL = "https://example.com"
LIST_URL = BASE_URL + "/products?page={page}"
USER_AGENT = "sample-collector/1.0 (+https://example.com/contact)"
INTERVAL_SEC = 2.0
MAX_PAGES = 5
OUT_DIR = Path("/home/username/scraper/data")
def require_pandas3():
major = int(pd.__version__.split(".")[0])
if major < 3:
sys.exit(f"pandas {pd.__version__} が読み込まれました。venv の python で実行しているか確認してください")
def text_of(item, selector):
node = item.select_one(selector)
return node.get_text(strip=True) if node else None
def fetch_rows():
rp = robotparser.RobotFileParser(BASE_URL + "/robots.txt")
rp.read()
delay = max(INTERVAL_SEC, rp.crawl_delay(USER_AGENT) or 0)
session = requests.Session()
session.headers["User-Agent"] = USER_AGENT
rows = []
for page in range(1, MAX_PAGES + 1):
url = LIST_URL.format(page=page)
if not rp.can_fetch(USER_AGENT, url):
print(f"robots.txt で許可されていないため中止: {url}")
break
res = session.get(url, timeout=30)
res.raise_for_status()
items = BeautifulSoup(res.text, "html.parser").select("div.item")
if not items:
break
for item in items:
rows.append({
"name": text_of(item, ".name"),
"price_text": text_of(item, ".price"),
"stock_text": text_of(item, ".stock"),
})
time.sleep(delay)
return rows
def transform(rows):
df = pd.DataFrame(rows, columns=["name", "price_text", "stock_text"])
df = df.assign(
price=lambda d: pd.to_numeric(
d["price_text"].str.replace(r"[^0-9]", "", regex=True),
errors="coerce",
),
in_stock=lambda d: d["stock_text"].str.contains("在庫あり", na=False),
name=lambda d: d["name"].fillna("(名称なし)"),
)
df["band"] = "通常"
df.loc[df["price"] >= 10000, "band"] = "高価格帯"
df.loc[df["price"].isna(), "band"] = "価格不明"
return df
def main():
require_pandas3()
rows = fetch_rows()
if not rows:
sys.exit("1件も取得できませんでした。HTML構造が変わっていないか確認してください")
df = transform(rows)
today = datetime.now(ZoneInfo("Asia/Tokyo")).strftime("%Y%m%d")
OUT_DIR.mkdir(parents=True, exist_ok=True)
out = OUT_DIR / f"items_{today}.csv"
df.to_csv(out, index=False, encoding="utf-8-sig")
print(f"{len(df)}件を保存: {out}")
if __name__ == "__main__":
main()
ブロックごとの説明
- 定数部分:取得先・User-Agent・アクセス間隔・最大ページ数を冒頭にまとめています。
MAX_PAGESは「想定外にページが続いても無限に取りに行かない」ための上限です require_pandas3():起動直後に pandas のメジャーバージョンを確かめ、3 未満なら理由を書いて終了コード1で止めます。後述の systemd で「venv ではなく OS の python3 で動いていた」事故を、黙って古い挙動で走らせずに失敗として表に出すためのものですtext_of():要素が見つからないときにNoneを返します。select_one(...).get_text()を直接書くと、1件だけ構造が違う商品があっただけでAttributeErrorで全体が止まるためです。欠けた値は pandas 側で欠損として扱いますfetch_rows():最初に robots.txt を読み、Crawl-delayの指定があればそれと自前の2秒の長い方を待ち時間にします。各ページの取得前にcan_fetch()で許可を確かめ、商品が0件のページに当たったら最終ページとみなして抜けますtransform()のassign():価格文字列から数字以外を取り除いて数値化(変換できないものは欠損)、在庫文字列から真偽値の列を作り、名称の欠損を埋めています。lambda d:のdは「その時点の DataFrame」で、assign()は新しい DataFrame を返すので、df = df.assign(...)と受け取り直します。元の表に書き込む操作が1つも無いので、Copy-on-Write の影響を受けませんtransform()の.loc:先にband列を「通常」で作ってから、条件に合う行だけを.loc[条件, "band"]で書き換えます。冒頭の対応表の左列だった部分が、ここで正しくdf本体に反映されますmain():0件ならsys.exit()にメッセージを渡して終了コード1で終わります。サイト側のHTML変更でセレクタが外れたとき、空のCSVを「成功」として残さないためです。日付は日本時間で付け、Excel で文字化けしないようutf-8-sigで保存します
もう1つの既定変更:文字列列が str 型になる
pandas 3.0 では、文字列の列が以前の object 型ではなく str 型として作られます。.str.replace() や .str.contains() はそのまま動きますが、型を判定している箇所は結果が変わります。
以前の書き方 | pandas 3.0 で起きること | 今の書き方 |
|---|---|---|
| 文字列列が |
|
| 文字列列が選ばれないことがある | 列を回して |
| 欠損は |
|
内部の実装は pyarrow が入っているかどうかで変わるため、挙動を揃えたいなら pip show pyarrow で手元とサーバーの両方の有無を確認しておきます。文字列の列に数値を混ぜる設計は避け、数値は上のコードの price のように別の列に分けるのが安全です。
取得先サイトへの配慮:robots.txt・利用規約・アクセス間隔
毎日動かすスクリプトは、相手のサーバーから見れば毎日来る訪問者です。コードに入れたもの・入れていないものを整理します。
- robots.txt:上のコードは
urllib.robotparserで許可を確かめ、Crawl-delayがあれば従います。robots.txt は後から変わることがあり(2024年7月に大手掲示板サイトが特定の検索エンジン以外を締め出す形に変更した例があります)、毎回読み直すのはそのためです - アクセス間隔:1秒に1リクエスト程度が一般的な目安とされますが、上のコードは余裕を持たせて2秒にしています。1日1回・最大5ページなので、相手から見た負荷はごく小さく収まります
- 利用規約:自動収集やAI学習目的の収集を禁じる条項を追加するサイトが増えています。禁止されていれば、そのサイトからは取らないのが原則です。公開APIがあるならそちらを使います
- ログインが要るページは対象にしない:日本では不正アクセス禁止法が認証で守られたシステムへの不正なアクセスを罰しています。米国の判例でも、ログインの有無が扱いの分かれ目になっています(2024年1月の Meta v. Bright Data など)
- ブロックされたら止める:CAPTCHA や IP ブロックが出たら、それは「来ないでほしい」という意思表示です。回避策を探すのではなく、取得をやめるか先方に問い合わせます
- 個人データは集めない:日本の著作権法第30条の4は情報解析目的の複製を広く認めていますが、個人情報の扱いは別の法律の話です。EUの GDPR 違反では、スクレイピングを巡ってオランダで3050万ユーロの制裁金が科された例もあります。人名や連絡先が並ぶページは対象から外します
開発中は同じページを何度も取りに行かないことも配慮のうちです。一度保存したHTMLを BeautifulSoup(Path("sample.html").read_text(), "html.parser") で読んで transform() を試せば、加工部分の調整で相手にアクセスする必要はありません。
サーバーの venv で pandas>=3 を固定し、systemd timer で毎日動かし続ける
venv を作ってバージョンを固定する
cd /home/username/scraper
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/python -c "import pandas; print(pandas.__version__)"
.venv/bin/pip freeze > requirements.lock
requirements.txt の中身は次のとおりです。
pandas>=3,<4
requests
beautifulsoup4
requirements.txt は「許す範囲」、requirements.lock は「今動いている正確なバージョン」の記録です。サーバーを作り直すときは .venv/bin/pip install -r requirements.lock で同じ状態を再現できます。パッケージ管理を高速化したいなら uv を使う選択肢もあり、uv venv と uv pip install -r requirements.txt で同じことができます。
ここで大事なのは、実行するときも必ず .venv/bin/python を絶対パスで指定することです。OS のパッケージとして古い pandas が入っていると、python3 collect.py と書いただけでそちらが読み込まれ、Copy-on-Write 以前の挙動で動きます。require_pandas3() はこの取り違えを検知するための保険です。
systemd の service と timer を置く
/home/username/.config/systemd/user/scraper.service:
[Unit]
Description=Daily scraping job
[Service]
Type=oneshot
WorkingDirectory=/home/username/scraper
ExecStart=/home/username/scraper/.venv/bin/python /home/username/scraper/collect.py
/home/username/.config/systemd/user/scraper.timer:
[Unit]
Description=Run scraper daily
[Timer]
OnCalendar=*-*-* 06:30:00
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
Type=oneshot:1回走って終わる処理であることを示しますPersistent=true:電源が落ちていて時刻を逃した回を、起動後に1回実行しますRandomizedDelaySec=300:開始を最大5分ずらし、毎日ぴったり同じ秒にアクセスしないようにしますOnCalendarはサーバーのタイムゾーンで解釈されるので、timedatectlで確認しておきます
有効化と確認のコマンドです。
systemctl --user daemon-reload
systemctl --user enable --now scraper.timer
systemctl --user list-timers
sudo loginctl enable-linger username
systemctl --user start scraper.service # 一度手動で実行して確かめる
journalctl --user -u scraper.service -n 50
loginctl enable-linger を入れないと、ログアウトした時点でユーザー単位の timer が止まります。collect.py は取得0件や pandas の取り違えで終了コード1を返すので、systemctl --user status scraper.service に失敗として残ります。ログはディスクを消費し続けるので、容量の点検方法は journalctl --disk-usageでログ点検2026 を参考にしてください。
どこで動かすか:止まる条件で選ぶ
置き場所 | 向いている条件 | 止まる条件 |
|---|---|---|
手元のPC(launchd・cron・タスクスケジューラ) | 試験運用、数日に1回で足りる | スリープ・電源オフ・ノートPCの蓋を閉じたとき。持ち歩くPCでは「毎日」は保証できない |
共有レンタルサーバーの cron | requests + BeautifulSoup だけで、1日1回数分で終わる | Python のバージョンを選べず |
VPS・常時稼働の自宅機(systemd timer) | ブラウザ操作が要る、処理が長い、失敗を検知して直したい | OS更新で Python が入れ替わり venv が壊れる、ログでディスクが埋まる、停電、契約の失効 |
共有レンタルサーバーの cron に置く場合は、次のように venv の python を絶対パスで指定し、出力をファイルに残します。
30 6 * * * /home/username/scraper/.venv/bin/python /home/username/scraper/collect.py >> /home/username/scraper/cron.log 2>&1
VPS の場合、OS のメジャー更新で Python が入れ替わると venv 内の python が参照先を失います。更新後は .venv/bin/python -c "import pandas" を一度実行し、動かなければ venv を作り直して requirements.lock から入れ直します。
関連する選択肢
毎日決まった時刻にスクリプトを動かすだけなら、共有のレンタルサーバーでも足ります。cronが使えるプランを選べば、自分でOSを管理する必要はありません。
※ 広告を含みます(A8.net)。リンク経由でお申し込みがあった場合、手数料を受け取ることがあります。
まとめ
pandas 3.0 では Copy-on-Write が既定になり、df["列"][条件] = 値 や df["列"].fillna(..., inplace=True) のような連鎖代入は元の DataFrame に反映されなくなりました。例外で止まらず警告だけで終わるため、定期実行では「正常終了しているのに加工されていないCSV」として現れます。今は、行と列を同時に指定する書き込みは .loc[条件, "列"]、列の作り直しは df = df.assign(...) で新しい表を受け取る形に揃えます。文字列列が str 型になった点も、dtype == object の判定を is_string_dtype() に置き換えて対応します。運用面では、venv で pandas>=3,<4 を固定して requirements.lock に記録し、systemd timer からは venv の python を絶対パスで呼び、起動時にバージョンを確かめて取り違えを失敗として表に出すこと。robots.txt と間隔を守り、ログインや制限の先には踏み込まない——これで毎日静かに動き続けるスクリプトになります。