ChatGPTやClaude Codeに書かせた価格の定点観測スクリプトが、ある朝から集計の途中で止まっている。原因として多いのは、pandas 3.0(2026年1月リリース)で DataFrame.applymap と fillna(method="ffill") が削除されたことです。生成AIは今もこの古い書き方を出してきます。この記事では、AIに DataFrame.map と ffill() の書き方へ直させる指示文を実物で示します。あわせて、版を固定して毎日止めずに動かすところまで手順を説明します。

pandas 3.0で applymap と fillna(method="ffill") が消え、毎朝の集計が止まる仕組み

価格の定点観測は、たとえば次のような流れのスクリプトです。毎朝、商品ページから価格を取ってCSVに追記し、日付×商品の表に組み直します。取れなかった日は前日の値で埋め、「¥1,980」のような表示に整えて通知します。生成AIにこの用途のコードを頼むと、「前日の値で埋める」に fillna(method="ffill")、「全セルを整形」に applymap を使う形がよく出てきます。

この2つは pandas 2系の間は警告を出しながらも動いていました。そのため、手元で一度動いた時点で「完成」と判断されがちです。ところが次のような場面で、版を指定しない pip install pandas が走ると 3.0 以降が入り、その日から止まります。

  • 新しいサーバーや仮想環境を作り直した
  • 他のパッケージを入れたついでに pip install -U した
  • レンタルサーバー側でPython環境が更新された

applymap の行では次のように落ちます。fillna の行では method という引数を受け付けないエラーになります。

AttributeError: 'DataFrame' object has no attribute 'applymap'

定期実行では、この1行が誰にも見られないままログに残るだけです。数日後に「最近通知が来ない」と気づくことになります。

以前の書き方と今の書き方

やりたいこと

以前の書き方(3.0で削除)

今の書き方

全セルに関数を適用

df.applymap(f)

df.map(f)

欠けた日を前日の値で埋める

df.fillna(method="ffill")

df.ffill()

欠けた日を翌日の値で埋める

df.fillna(method="bfill")

df.bfill()

右側の書き方は pandas 2系の後半でも使えます。そのため、今のうちに書き換えておけば2系でも3系でも同じコードで動きます。自分の環境の版は pip show pandas の「Version:」行で確認できます。

AIへの最初の指示と、出てきたコードの4つの問題

2026年10月7日にはChatGPTに「GPT-6」が投入されました。Claudeも9月28日に「Sonnet 5.5」、10月7日に「Haiku 5.5」が出ています。ただ、モデルがどれだけ新しくなっても、AIはあなたの環境にどの版のpandasが入っているかを知りません。版を伝えずに頼むと、ネット上に大量に残っている古い書き方が混ざります。

最初の指示(よくある頼み方)

Pythonで、毎朝商品ページから価格を取ってCSVに貯め、
日付×商品の表にして直近7日分をWebhookで通知するスクリプトを書いてください。
取れなかった日は前日の価格で埋めて、価格は「¥1,980」の形式で表示してください。

出てきたコード(典型例)

import requests
from bs4 import BeautifulSoup
import pandas as pd
from datetime import date

WEBHOOK = "https://hooks.example.com/services/xxxxxxxx"
URLS = [
    "https://example.com/products/1",
    "https://example.com/products/2",
]

rows = []
for url in URLS:
    try:
        html = requests.get(url).text
        soup = BeautifulSoup(html, "html.parser")
        name = soup.select_one("h1").text
        price = int(soup.select_one(".price").text.replace("¥", "").replace(",", ""))
        rows.append({"date": date.today(), "name": name, "price": price})
    except:
        pass

df = pd.read_csv("prices.csv")
df = pd.concat([df, pd.DataFrame(rows)])
df.to_csv("prices.csv", index=False)

table = df.pivot_table(index="date", columns="name", values="price")
table = table.fillna(method="ffill")
table = table.applymap(lambda x: f"¥{x:,.0f}")
requests.post(WEBHOOK, json={"text": table.tail(7).to_string()})

一見それらしく動きますが、毎日動かす前提で読むと問題が4つあります。

  1. 削除済みのAPI:fillna(method="ffill") と applymap。pandas 3.0以降ではこの2行で止まります。
  2. アクセス間隔が無い:ループに待ち時間が無く、URLが増えると相手のサーバーに連続で要求を投げます。timeout も無いため、相手が応答しないとそこで永久に待ち続けます。
  3. 例外で黙って止まる:except: pass のせいで、ページ構造が変わって価格が1件も取れなくても何も言わずに終わります。最後の通知も「前日の値で埋めた表」なので、取れていないことにすら気づけません。
  4. APIキーの直書き:Webhook URLは、知っていれば誰でもそこへ投稿できる鍵と同じです。コードに書くと、共有・バックアップ・AIへの貼り付けのたびに外へ出ていきます。

このほか、毎回 prices.csv が「スクリプトを起動した場所」に作られる点も問題です。cronやsystemdから動かすと、別のディレクトリに別のCSVができて履歴が分断されます。

直させる指示と、DataFrame.map・ffill()に直った後のコード

直させる指示(実物)

ポイントは2つです。1つは pip show pandas の結果をそのまま貼ること。もう1つは、直してほしい箇所を「毎日無人で動かす」という前提と一緒に列挙することです。「エラーが出ました」だけを渡すと、エラーの行だけ直して他の問題が残ります。

このスクリプトを、サーバーで毎朝無人実行する前提で直してください。
私の環境のpandasは以下です(pip show pandas の結果):
Name: pandas
Version: (ここに表示された版を貼る)

直してほしい点:
1. この版で削除・非推奨になっているAPIを使わない。
   applymap は DataFrame.map に、fillna(method=...) は ffill()/bfill() にする
2. 各URLの取得の間に5秒以上待つ。requests には timeout を付ける
3. 通信エラーは最大3回まで間隔を空けて再試行し、それでも駄目なURLは記録する
4. except: pass は使わない。取れなかったURLがあれば通知し、終了コードを1にする
5. Webhook URLはコードに書かず、環境変数 NOTIFY_WEBHOOK_URL から読む
6. CSVはスクリプトと同じディレクトリに置く。同じ日の同じ商品は重複させない
変更した箇所と理由を、コードの後に箇条書きで説明してください。

最後の「変更した箇所と理由を説明させる」は省かないでください。説明と実際のコードが食い違っていれば、そこがAIの見落としです。

直った後のコード

実行環境は pandas 3系、または DataFrame.map が使える2系後半です。対応するPythonの下限は版ごとに変わるので、python3 -V で手元の版を確かめ、pandasの公式インストール要件と照らしてください。必要なパッケージは pip install pandas requests beautifulsoup4 です。

import logging
import os
import sys
import time
from datetime import date
from pathlib import Path

import pandas as pd
import requests
from bs4 import BeautifulSoup

BASE = Path(__file__).resolve().parent
CSV = BASE / "prices.csv"
URLS = [
    "https://example.com/products/1",
    "https://example.com/products/2",
]
INTERVAL = 5          # 1ページごとの待ち時間(秒)
RETRIES = 3
HEADERS = {"User-Agent": "price-watch/1.0 (+https://example.com/contact)"}
WEBHOOK = os.environ.get("NOTIFY_WEBHOOK_URL")

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")


def fetch(url):
    for i in range(1, RETRIES + 1):
        try:
            r = requests.get(url, headers=HEADERS, timeout=20)
            r.raise_for_status()
            return r.text
        except requests.RequestException as e:
            logging.warning("取得失敗 %s (%d/%d): %s", url, i, RETRIES, e)
            time.sleep(30)
    return None


def parse(html):
    soup = BeautifulSoup(html, "html.parser")
    name = soup.select_one("h1")
    price = soup.select_one(".price")
    if name is None or price is None:
        return None
    digits = "".join(c for c in price.get_text() if c.isdigit())
    if not digits:
        return None
    return name.get_text(strip=True), int(digits)


def notify(text):
    if not WEBHOOK:
        logging.info("通知先が未設定のため出力のみ:\n%s", text)
        return
    requests.post(WEBHOOK, json={"text": text}, timeout=20).raise_for_status()


def main():
    rows, failed = [], []
    for url in URLS:
        html = fetch(url)
        item = parse(html) if html else None
        if item is None:
            failed.append(url)
        else:
            rows.append({"date": date.today().isoformat(), "name": item[0], "price": item[1]})
        time.sleep(INTERVAL)

    if not rows:
        notify("価格を1件も取得できませんでした:\n" + "\n".join(failed))
        sys.exit(1)

    frames = [pd.read_csv(CSV)] if CSV.exists() else []
    frames.append(pd.DataFrame(rows))
    df = pd.concat(frames, ignore_index=True)
    df = df.drop_duplicates(subset=["date", "name"], keep="last")
    df.to_csv(CSV, index=False)

    table = df.pivot_table(index="date", columns="name", values="price", aggfunc="last")
    table = table.ffill()
    shown = table.map(lambda x: f"¥{x:,.0f}", na_action="ignore")

    text = shown.tail(7).to_string()
    if failed:
        text += "\n\n取得できなかったURL:\n" + "\n".join(failed)
    notify(text)

    if failed:
        sys.exit(1)


if __name__ == "__main__":
    main()

ブロックごとの説明

  • 冒頭の定数:BASE でCSVの置き場所をスクリプト自身の場所に固定しています。これで、cronやsystemdから起動しても同じファイルに追記されます。待ち時間・再試行回数・連絡先入りのUser-Agentもここにまとめ、Webhook URLは環境変数から読みます。
  • fetch():timeout=20 で無限待ちを防ぎます。raise_for_status() で403や500も失敗として扱い、30秒空けて最大3回まで試します。それでも取れなければ None を返し、呼び出し側で「失敗」として記録させます。
  • parse():要素が見つからない・数字が無いときは、例外を握りつぶさず None を返します。ページ構造が変わったときに「取れなかったURL」として表に出るのはこの関数のおかげです。
  • main() の前半:1件取るごとに INTERVAL 秒待ちます。全滅なら通知して終了コード1で終わります。CSVが無い初回でも動くように読み込みを分岐し、同じ日に2回動かしても重複しないよう drop_duplicates で日付×商品を1行にしています。
  • main() の後半:ここが今回の主役です。前日の値で埋める処理は table.ffill() に、全セルの整形は table.map(...) に置き換えています。na_action="ignore" を付けると、観測開始前の空欄(埋める前日の値が無いセル)に関数を当てずに済みます。
  • 最後の sys.exit(1):一部でも取れなかった日は異常終了にします。こうしておくと、cronやsystemdの側で「失敗した日」として記録が残ります。

版を固定する

今回のように、メジャー版が上がると関数が消えることがあります。動作を確認した環境は仮想環境(venv)に閉じ込め、版を書き残しておきます。

python3 -m venv .venv
. .venv/bin/activate
pip install pandas requests beautifulsoup4
python price_watch.py          # 動作確認
pip freeze > requirements.txt  # 確認できた版をそのまま記録

別の環境に移すときは pip install -r requirements.txt で同じ版を入れます。厳密に揃えたくない場合は、pandas>=3,<4 のように「メジャー版だけ固定」する書き方もあります。この形だと修正版は受け取りつつ、次に関数が消えうるメジャー更新は勝手に入りません。考え方はBeautifulSoupの引数変更と同じです。bs4 text=非推奨→string=と版固定|26年10月 でも同じ手順を扱っています。

AIが出したコードで自分で確認すべき4箇所と、取得先サイトへの配慮

コードを読み慣れていなくても、検索コマンドで機械的に見つけられます。AIから受け取ったら、動かす前に次を流してください。

確認コマンド

確認すること

コマンド

引っかかったら

削除済みのpandas API

grep -nE "applymap|fillna\(method" *.py

map・ffill()・bfill() に直させる

アクセス間隔・タイムアウト

grep -nE "sleep|timeout" *.py

1件も出なければ、待ち時間と timeout を足させる

例外の握りつぶし

grep -n -A1 "except" *.py

次の行が pass なら、記録と通知に変えさせる

キー・URLの直書き

grep -niE "(key|token|secret|webhook)\s*=\s*[\"']" *.py

環境変数から読む形に変えさせる

pandas 2系の環境が残っているなら、次のコマンドで一度テスト実行するのも有効です。将来消える書き方を警告ではなくエラーとして止められるので、3系に上げる前に洗い出せます。

python3 -W error::FutureWarning price_watch.py

ただし他のライブラリの警告もエラーになるため、これは確認用の実行に限ります。定期実行には付けません。

取得先サイトへの配慮

  • robots.txt を見る:https://example.com/robots.txt を開き、取得したいパスが Disallow されていないか確認します。
  • 利用規約を見る:自動取得を禁止しているサイトや、公式APIを提供しているサイトがあります。APIがあるならそちらを使います。
  • 間隔と回数:価格の定点観測なら1ページ1日1回で足ります。上のコードでは1件ごとに5秒待ち、失敗時の再試行も30秒空けて3回までにしています。
  • 止められたら止める:403が続く、確認画面が出るといった状況は、相手が拒否しているという意思表示です。User-Agentの偽装やアクセス元の切り替えで回避するのではなく、取得をやめるか、公式の手段に切り替えます。

AIに頼むときも、指示文に「取得の間に5秒以上待つ」と書いておかないと、間隔の無いコードが出てくることが多いです。

毎朝止めずに動かす場所:手元のPC・レンタルサーバーのcron・VPSのsystemd timer

コードが直っても、動かす場所が止まれば観測は途切れます。定点観測は「毎日欠けずに取れていること」に価値があるので、ここは省けません。

動かす場所

向いている条件

止まる条件

手元のPC

試しに数日回したい/取れない日があってもよい

スリープ・電源オフ・ログアウト中は実行されない。ノートPCを閉じた日はその日の分が抜ける

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

1回数分で終わる・常駐不要・pip で好きな版を入れられる

Pythonやpandasの版を自分で選べないプランでは使えない。サーバー側の更新で環境が変わる。実行時間やプロセス数の制限を超えると強制終了される

VPS(systemd timer)

版を自分で固定したい・ブラウザ自動化も使いたい・失敗を記録したい

OS更新やディスク逼迫など管理を自分で行う必要がある。契約や支払いが切れれば止まる

レンタルサーバーのcronで動かす場合

cronは環境変数をほとんど引き継がないので、venvのPythonを絶対パスで指定し、出力をログに残します。

0 7 * * * cd /home/username/price-watch && NOTIFY_WEBHOOK_URL="$(cat /home/username/.price_webhook)" /home/username/price-watch/.venv/bin/python price_watch.py >> /home/username/price-watch/run.log 2>&1

.price_webhook は chmod 600 にして、本人以外が読めないようにします。

VPSでsystemd timerを使う場合

調査でも、cronの代わりにsystemdのTimerユニットを使う方法が挙がっていました。ログの一元管理ができ、止まっていた間の分を起動時に取り戻せるのが利点です。ユーザー単位で置く例を示します。

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

[Service]
Type=oneshot
WorkingDirectory=/home/username/price-watch
EnvironmentFile=/home/username/price-watch/.env
ExecStart=/home/username/price-watch/.venv/bin/python price_watch.py
# /home/username/.config/systemd/user/price-watch.timer
[Timer]
OnCalendar=*-*-* 07:00
Persistent=true

[Install]
WantedBy=timers.target
chmod 600 /home/username/price-watch/.env      # 中身は NOTIFY_WEBHOOK_URL=... の1行
systemctl --user daemon-reload
systemctl --user enable --now price-watch.timer
loginctl enable-linger username                 # ログアウト後もタイマーを動かす
systemctl --user list-timers                    # 次回の実行予定を確認
journalctl --user -u price-watch.service        # 実行ログを見る

Persistent=true を付けると、サーバーが止まっていた時間帯の分が起動時に1回実行されます。loginctl enable-linger を忘れると、SSHを切った時点でタイマーも止まります。スクリプト側で失敗時に終了コード1を返しているので、journalctl を見れば失敗した日がすぐ分かります。

取得した価格をCSVではなくSQLiteに貯める形に育てる場合は、日付の扱いにも版の差があります。sqlite3 datetime非推奨と重複通知|26年10月 を合わせて確認してください。

関連する選択肢

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

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

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

版を伝えてDataFrame.mapとffill()に直させ、固定して毎朝動かす要点

pandas 3.0(2026年1月リリース)では applymap と fillna(method="ffill") が削除されました。今の書き方は DataFrame.map と ffill() です。生成AIはあなたの環境の版を知らないので、指示文には pip show pandas の結果を貼ります。そのうえで、「削除済みAPIを使わない」「5秒以上待つ」「例外を握りつぶさず終了コード1」「Webhookは環境変数から」を列挙して直させます。

受け取ったコードは、grep で4箇所(古いAPI・間隔・except・直書き)を確かめてから動かしてください。動いたら pip freeze で版を記録します。動かす場所は、欠けてもよいなら手元のPC、版を選べるならレンタルサーバーのcron、確実に毎日回すならVPSのsystemd timerです。それぞれの止まる条件を把握したうえで選んでください。