requestsで取ったHTMLをpd.read_html(res.text)にそのまま渡していませんか。pandasではこの書き方が非推奨になり、削除も予告されています。cronで毎日回しているスクリプトほど、pandasを上げた日から黙って動かなくなりがちです。この記事では、今の書き方pandas read_htmlをStringIOで包む方法、表をCSVに保存する完成コード、cron・systemd timerで毎日止めずに回す設定までを順に説明します。
pandas read_htmlに渡すHTML文字列はio.StringIOで包む
以前の書き方と今の書き方
以前は、requestsで取得したHTMLの文字列をそのままread_htmlに渡すのが定番でした。
# 以前の書き方(今は使わない)
import pandas as pd
import requests
res = requests.get("https://example.com/stats/table.html", timeout=20)
tables = pd.read_html(res.text)
今は、文字列をio.StringIOで包んでから渡します。違いは1行だけです。
# 今の書き方
from io import StringIO
import pandas as pd
import requests
res = requests.get("https://example.com/stats/table.html", timeout=20)
tables = pd.read_html(StringIO(res.text))
StringIOは、文字列をファイルのように読める入れ物です。read_htmlは「ファイルとして開けるもの」を受け取ってHTMLとして解析するので、中身は以前と同じように表のリスト(DataFrameのリスト)で返ってきます。
包まなければならない理由
以前のread_htmlは、文字列を受け取ると「ファイルパスか、URLか、HTMLそのものか」を中身から推測していました。この推測をやめて入力の意味をはっきりさせるため、pandas 2.1でHTML文字列の直接渡しが非推奨になりました。古い書き方のままだと、実行のたびに次の警告が出ます。
FutureWarning: Passing literal html to 'read_html' is deprecated and will be removed in a future version. To read from a literal string, wrap it in a 'StringIO' object.
警告の文面どおり、将来のバージョンでの削除が予告されています。3.0系に上げたあとも同じ書き方が通るかを当てにせず、今のうちにStringIOへ書き換えておくのが安全です。StringIOで包む書き方は、2.1以降の2.x系でも3.0系でもそのまま動きます。なお、リリースノートではread_json・read_xmlへの文字列の直接渡しも同じ扱いになっています。使っている場合は公式のリリースノートで確認してください。
実行環境とインストールするもの
- Python:3.11以降を想定しています。手元の版は
python3 -Vで確認できます。pandasの各版が対応するPythonの版は、公式ドキュメントのインストールページで確認してください。 - パッケージ:
pip install requests pandas lxml lxmlはread_htmlがHTMLを解析するときに使います。入っていないとread_htmlは動きません。- 入っているpandasの版は
pip show pandasで確認できます。
仮想環境(venv)を作ってから入れると、OSのPythonを汚さずに済みます。後で定期実行の設定から呼ぶのも、この仮想環境のPythonです。
python3 -m venv /home/username/scraper/.venv
/home/username/scraper/.venv/bin/pip install requests pandas lxml
requests+pd.read_html(StringIO(res.text))で表をCSVに保存する全コード
公開ページの表を1日1回取得し、日付つきのCSVに保存するスクリプトです。毎日自動で回す前提なので、失敗したら終了コード(プログラムが終わるときにOSへ返す数値)を0以外にするところまで含めています。こうしておくと、cronやsystemdの側で失敗を見分けられます。
#!/usr/bin/env python3
"""公開されている表を1日1回取得してCSVに保存する"""
import sys
import time
from datetime import date
from io import StringIO
from pathlib import Path
from urllib import robotparser
from urllib.parse import urljoin
import pandas as pd
import requests
URL = "https://example.com/stats/table.html"
OUT_DIR = Path("/home/username/scraper/data")
HEADERS = {"User-Agent": "table-fetcher/1.0 (+contact@example.com)"}
TIMEOUT = 20
RETRIES = 3
WAIT = 5
EXPECTED = ["品目", "価格", "更新日"]
def allowed_by_robots(url: str) -> bool:
rp = robotparser.RobotFileParser()
rp.set_url(urljoin(url, "/robots.txt"))
rp.read()
return rp.can_fetch(HEADERS["User-Agent"], url)
def fetch_html(url: str) -> str:
last_err = None
for attempt in range(1, RETRIES + 1):
try:
res = requests.get(url, headers=HEADERS, timeout=TIMEOUT)
except (requests.ConnectionError, requests.Timeout) as e:
last_err = e
else:
if res.status_code >= 500:
last_err = f"HTTP {res.status_code}"
else:
res.raise_for_status() # 403・404・429 などは再試行しない
if not res.encoding or res.encoding.lower() == "iso-8859-1":
res.encoding = res.apparent_encoding
return res.text
if attempt < RETRIES:
time.sleep(WAIT * attempt)
raise RuntimeError(f"{RETRIES}回試して取得できませんでした: {last_err}")
def parse_table(html: str) -> pd.DataFrame:
tables = pd.read_html(StringIO(html), match="品目")
df = tables[0]
missing = [c for c in EXPECTED if c not in df.columns]
if missing:
raise ValueError(f"想定した列がありません: {missing}")
if df.empty:
raise ValueError("表が空です")
df["価格"] = pd.to_numeric(
df["価格"].astype(str).str.replace(r"[^\d.]", "", regex=True),
errors="coerce",
)
df["取得日"] = date.today().isoformat()
return df
def main() -> int:
if not allowed_by_robots(URL):
print("robots.txt で許可されていないため取得しません", file=sys.stderr)
return 2
df = parse_table(fetch_html(URL))
OUT_DIR.mkdir(parents=True, exist_ok=True)
out = OUT_DIR / f"table_{date.today():%Y%m%d}.csv"
tmp = out.with_suffix(".tmp")
df.to_csv(tmp, index=False, encoding="utf-8-sig")
tmp.replace(out)
print(f"{len(df)}行を保存しました: {out}")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as e:
print(f"失敗: {e}", file=sys.stderr)
sys.exit(1)
冒頭の定数:取得先・保存先・名乗り方を1か所にまとめる
URLとOUT_DIRは、取得するページと保存先です。HEADERSのUser-Agent(アクセス元のプログラムを名乗る文字列)には、ツール名と連絡先を入れています。ブラウザになりすますためではなく、相手のサイト管理者が「何のアクセスか」「誰に連絡すればよいか」を分かるようにするためです。EXPECTEDには、取りたい表に必ずあるはずの列名を並べます。
allowed_by_robots():取得前にrobots.txtを確認する
標準ライブラリのurllib.robotparserで取得先の/robots.txtを読み、このUser-AgentでこのURLを取ってよいかを判定します。許可されていなければ取得せず、終了コード2で終わります。
fetch_html():再試行してよい失敗だけを再試行する
- 接続エラー・タイムアウト・5xx(相手サーバー側の一時的な不調)だけを、5秒→10秒と間隔を広げながら最大3回まで試します。
- 403・404・429などの4xxは
raise_for_status()で例外にし、そこで止めます。特に403(拒否)と429(アクセスが多すぎる)は「来ないでほしい」という相手の意思表示です。繰り返し叩き直してはいけません。 timeout=20を必ず付けます。付けないと、応答のないサーバーを相手にいつまでも待ち続けることがあり、定期実行では「止まっているのに失敗にもならない」状態になります。- 日本語のサイトで文字コードの宣言が無いと、requestsは
ISO-8859-1とみなして文字化けすることがあります。その場合だけapparent_encoding(本文から推定した文字コード)に置き換えています。
parse_table():StringIOで渡し、表の形が変わったら止める
主役のpd.read_html(StringIO(html), match="品目")がここです。matchには文字列を指定し、その文字列を含む表だけを返させます。ページに表が複数あっても、順番に頼らずに目的の表を選べます。条件に合う表が1つも無いとread_htmlはValueErrorを出すので、サイトの構造が変わった日はそのまま失敗として扱われます。
その後、想定した列がそろっているか、表が空でないかを確認しています。列名が変わったのに処理を続けると、空や的外れなCSVが毎日正常終了で積み上がります。それを防ぐための確認です。価格の列は「1,980円」のような文字列で入ってくることが多いので、数字と小数点以外を落としてからpd.to_numericで数値にしています。変換できない値はerrors="coerce"で欠損値(NaN)になります。
main():一時ファイルに書いてから置き換える
CSVはいったん.tmpに書き、書き終わってからreplace()で本来の名前に置き換えます。途中で落ちても、書きかけのCSVが正式なファイルとして残りません。utf-8-sigは、ExcelでCSVを開いたときに日本語が化けないようにする指定です。最後のtry/exceptは、どこで落ちても終了コード1を返すための受け皿です。
JavaScriptで描画される表は取れない
read_htmlが読むのは、requestsが受け取ったHTMLに最初から書かれている<table>だけです。ページを開いた後にJavaScriptで表を組み立てるサイトでは、requestsが受け取るのは表の無い「ガワ」で、matchに合う表が見つかりません。調査でも、SPA(ページを切り替えずに中身を書き換える作り)やJavaScriptによる後読みは、スクレイピングの「3大つらみ」の1つに数えられています。その場合は、ブラウザの開発者ツールのネットワークタブで、表のデータを返しているリクエストを確認するのが先です。どうしてもブラウザで描画する必要があるなら、Playwrightで描画後のHTMLを取り、同じようにStringIOで包んでread_htmlに渡せます。Playwrightの待ち方はPlaywright locator移行と自動待機|26年10月にまとめています。
cron・systemd timerで毎日6:15に回し、止めずに動かし続ける設定
cron:絶対パスとflockで二重起動を防ぐ
# crontab -e で追加。毎日 6:15 に実行
15 6 * * * cd /home/username/scraper && /usr/bin/flock -n /tmp/fetch_table.lock .venv/bin/python fetch_table.py >> logs/fetch_table.log 2>&1
- cronは最小限の環境変数で動くため、
python3と書くと仮想環境ではなくOSのPythonが使われます。仮想環境のPythonを絶対パスかディレクトリからの相対パスで指定します。 flock -nは、前回の実行がまだ終わっていなければ今回の実行をあきらめる仕組みです。応答が遅い日に処理が重なるのを防げます。>> ... 2>&1で、通常の出力とエラー出力の両方をログに残します。logs/ディレクトリは事前に作っておきます。- 実行時刻を
0分ちょうどから外しているのは、多くの人が同じ時刻に設定しがちで、相手のサーバーにアクセスが集中しやすいからです。
uvでスクリプトに依存関係を書き込み、cronからuv run --scriptで呼ぶ方法もあります。仮想環境の管理を減らしたい場合はuv run --scriptでcron定期実行|26年10月を参照してください。
systemd timer:取り逃した回をあとで実行し、ログが残る
Linuxで常時動かすなら、私はcronよりsystemd timerを勧めます。電源が落ちていて実行時刻を逃した回を、起動後に実行してくれること、ログがjournalctlで日時つきで見られることが利点です。ユーザー単位のユニット(systemdの設定ファイル)として、次の2つを~/.config/systemd/user/に置きます。
# ~/.config/systemd/user/fetch-table.service
[Unit]
Description=公開表の日次取得
[Service]
Type=oneshot
WorkingDirectory=/home/username/scraper
ExecStart=/home/username/scraper/.venv/bin/python fetch_table.py
# ~/.config/systemd/user/fetch-table.timer
[Unit]
Description=公開表の日次取得(毎日6:15)
[Timer]
OnCalendar=*-*-* 06:15:00
RandomizedDelaySec=300
Persistent=true
[Install]
WantedBy=timers.target
systemctl --user daemon-reload
systemctl --user enable --now fetch-table.timer
loginctl enable-linger username # ログアウト後もユーザーのタイマーを動かす
systemctl --user list-timers # 次回の実行予定を確認
journalctl --user -u fetch-table.service # 実行ログを確認
Type=oneshotは「1回実行して終わる処理」という指定です。Persistent=trueで、停止中に逃した回を次の起動時に実行します。RandomizedDelaySec=300で、実行開始を最大5分ランダムにずらします。毎回まったく同じ時刻に叩かずに済みます。loginctl enable-lingerを入れないと、ログアウトした時点でユーザーのタイマーが止まります。
launchd(Mac):スリープ中は動かない
Macでは~/Library/LaunchAgents/にplist(設定ファイル)を置き、StartCalendarIntervalで時刻を指定します。ただし、フタを閉じてスリープしている間は実行されません。「毎日」の確実さが要るなら、次の表のように常時起動している場所へ移すことを考えます。
pandasを上げた日に止めない:版を固定し、警告を先に拾う
今回の件のように、ライブラリの非推奨はまず警告として出て、後の版で動かなくなります。cronやsystemdで動いているスクリプトの警告は、ログに流れるだけで誰も読みません。そこで次の2つを習慣にしています。
- 動作を確認した版を
pip freeze > requirements.txtで固定し、本番の仮想環境では勝手にpip install -Uしない。 - pandasを上げる前に、手元で
python -W error::FutureWarning fetch_table.pyを実行する。FutureWarning(将来変わる予定の書き方への警告)が例外として止まるので、削除予定の書き方が残っていればその行で分かります。
毎日動かし続ける場所と、それぞれが止まる条件
置き場所 | 向いている条件 | 止まる条件 |
|---|---|---|
手元のPC(cron/launchd) | 試験運用中・1日1回で多少の取り逃しは許容できる | スリープ・シャットダウン・OSの更新による再起動。Macはフタを閉じると動かない |
共有レンタルサーバーのcron | requests+pandasだけで完結し、数十秒で終わる処理 | プランによって、cronの最短間隔・1回の実行時間・プロセス数に制限がある。ブラウザ(Playwright・Selenium)は入れられないことが多い。利用規約でスクレイピング用途を制限している場合もあるので事前に確認する |
VPS・常時起動の自宅機(systemd timer) | 確実に毎日動かしたい・ブラウザも使う・取り逃しを後から実行したい | OSやPythonの更新は自分で管理する。ディスクがいっぱいになる、更新でPythonが変わり仮想環境が壊れる、といった止まり方は自分で監視する必要がある |
この記事のスクリプトは、requestsとpandasだけで数秒〜数十秒で終わる処理です。cronが使える共有サーバーで足ります。JavaScriptで描画される表のためにブラウザが要る、あるいは取り逃しを許容できない、という条件が加わった時点でVPSを検討するのが現実的です。
取得先サイトへの配慮:robots.txt・利用規約・1日1回のアクセス間隔
robots.txtを守るサイトが急増している
調査によると、Webトラフィックに占めるAIクローラーの割合は8ヶ月で2.6%から10.1%へと4倍に増えました。これを受けて、Amazon・Reddit・NYTなどの大手サイトがrobots.txtを更新し、AIクローラーを明示的にブロックしています。robots.txtの法的拘束力は断言できませんが、無視すれば技術的に遮断されたり、訴訟になったりするリスクが高まります。前述のコードのように、取得前に毎回機械的に確認する形にしておくのが確実です。
利用規約と、取得したデータの扱い
- Amazon・X(旧Twitter)・楽天など、大手サイトの多くは利用規約でスクレイピングや自動アクセスを禁止しています。規約で禁じられているサイトは対象にしません。
- ログインが必要なページの取得は、規約違反になる可能性が高くなります。不正アクセス禁止法などの法的リスクも伴うため、この記事のスクリプトは公開ページだけを対象にしています。
- 「公開されているデータなら自由に使ってよい」は誤解です。公開データでも著作権やプライバシーの保護を受けることがあります。日本の著作権法第30条の4は情報解析目的の利用に比較的寛容ですが、集めたデータそのものを販売し、元の市場と競合する使い方は例外の対象外です。
- 個人情報を含む表を扱う場合は、2026年7月10日に成立し7月17日に公布された個人情報保護法の改正も確認が必要です(罰則規定は2027年1月17日に先行して施行)。
アクセス間隔と回数
- 表が1日1回しか更新されないなら、取得も1日1回で十分です。更新頻度より多く取りに行く意味はありません。
- 複数ページを取る場合は、リクエストの間を最低1〜2秒、状況によっては数秒以上空け、同時に接続する数も絞ります。
- 403や429を受けたら再試行せず、その日は止めます。IPアドレスの切り替えやCAPTCHAの突破など、相手の制限をかいくぐる手段は使いません。制限は「来ないでほしい」という意思表示です。
- 公式APIやCSVのダウンロードが提供されているなら、スクレイピングよりそちらを優先します。
関連する選択肢
毎日決まった時刻にスクリプトを動かすだけなら、共有のレンタルサーバーでも足ります。cronが使えるプランを選べば、自分でOSを管理する必要はありません。
※ 広告を含みます(A8.net)。リンク経由でお申し込みがあった場合、手数料を受け取ることがあります。
read_htmlをStringIOで包み、止まっても気づける形で毎日回す
pandasのread_htmlにHTML文字列を直接渡す書き方は、2.1で非推奨になり、削除も予告されています。今の書き方はpd.read_html(StringIO(res.text))の1行で、2.x系でも3.0系でもそのまま動きます。毎日回すスクリプトでは、書き換えに加えて次の3点をそろえておくと止まりにくくなります。1つ目は、取得時にtimeoutを付け、再試行は5xxと接続エラーだけに絞ること。2つ目は、表の列や行数を確認し、構造が変わったら終了コード1で止めること。3つ目は、動作を確認したpandasの版を固定し、上げる前に-W error::FutureWarningで削除予定の書き方を洗い出すことです。置き場所は、数秒で終わる処理なら共有サーバーのcronで足ります。取り逃しを許容できないならsystemd timerのPersistent=trueが使えるVPSを選びます。そして取得前のrobots.txt確認と1日1回の頻度は、どの環境でも外さないでください。