管理画面の「CSVエクスポート」ボタンを毎朝押してファイルを保存する。手作業なら30秒でも、毎日続くと地味に負担です。Seleniumで自動化しようとして、ダウンロード完了の待ち方やheadless時の保存先で手間取った人も多いと思います。この記事では、Playwrightの expect_download と download.save_as を使って、headlessのままCSVを確実に受け取る書き方を、動くコードと一緒に説明します。後半では、サーバーのsystemd timerで毎日動かし続けるための設定まで扱います。

Playwright expect_download と save_as でCSVを受け取る:以前の書き方との違い

以前はこう書いていた

Seleniumでダウンロードを扱うときは、Chromeの設定(download.default_directory)で保存先を指定し、ボタンを押したあとフォルダを監視していました。具体的には、作成途中のファイル(.crdownload)が消えるまで time.sleep でループする書き方です。この方法には次の弱点があります。

  • ダウンロードが終わったかどうかを、ファイル名の変化から推測するしかない
  • 同じフォルダに前日のファイルが残っていると、どれが今回の分か判別しにくい
  • headless実行では、ダウンロードを許可する追加設定が必要になる場面があった

Playwrightでも、初期には page.click() のあとに page.wait_for_event("download") を呼ぶ書き方が見られました。ただしこの順番だと、クリックした瞬間にダウンロードが始まった場合、待ち受けを登録する前にイベントが過ぎてしまうことがあります。

今はこう書く

今は with page.expect_download() as info: の中でクリックします。待ち受けを先に登録してからボタンを押すので、イベントを取りこぼしません。ブロックを抜けると info.value に Download オブジェクトが入ります。あとは download.save_as() で好きな場所へ保存するだけです。

項目

以前(Selenium+フォルダ監視)

今(Playwright expect_download)

完了の判定

ファイル名の変化をsleepで監視

download.failure() が完了まで待ち、失敗時は理由を返す

保存先

ブラウザ設定で固定

save_as() で1件ごとに指定

元のファイル名

フォルダから探す

download.suggested_filename で取得

headless

追加設定が要る場面があった

そのままダウンロードできる

注意点がひとつあります。Playwrightが受け取ったファイルは一時領域に置かれ、ブラウザのコンテキストを閉じると削除されます。そのため、save_as() は必ず context.close() より前に呼んでください。

Python 3.11+Playwright 1.60系で動くCSVエクスポート取得スクリプト

実行環境の用意

Playwrightの最新は2026年5月リリースの1.60系です。取得したCSVをpandasで点検するので、Pythonは3.11以上にします。2026年1月21日リリースのpandas 3.0が、最低バージョンをPython 3.11に引き上げたためです。

python3 -V                      # 3.11以上であることを確認
python3 -m venv .venv
. .venv/bin/activate
pip install playwright pandas
playwright install --with-deps chromium
pip show playwright             # 入ったバージョンを確認

playwright install --with-deps chromium は、ブラウザ本体と、それを動かすOS側のライブラリをまとめて入れるコマンドです。OS側のライブラリを入れるにはsudo権限が要ります。また、Playwrightのブラウザ本体はPythonパッケージのバージョンと対になっています。pip install -U playwright で更新したら、playwright install もやり直してください。やり直さないと、更新した翌朝からブラウザが見つからず止まります。サーバーで動かすなら、requirements.txt で pip show の結果に合わせてバージョンを固定しておくのが安全です。

スクリプト全体

例として、ログインが必要な管理画面(admin.example.com)の注文一覧からCSVを取る場合を示します。ラベル名やボタン名は、実際の画面の表示に合わせて書き換えてください。

import os
import sys
from datetime import datetime
from pathlib import Path

import pandas as pd
from playwright.sync_api import sync_playwright

BASE_URL = "https://admin.example.com"
SAVE_DIR = Path("/home/username/exports")
STATE_FILE = Path("/home/username/.config/csv-export/state.json")


def login(page):
    page.goto(f"{BASE_URL}/login")
    page.get_by_label("メールアドレス").fill(os.environ["EXPORT_USER"])
    page.get_by_label("パスワード").fill(os.environ["EXPORT_PASSWORD"])
    page.get_by_role("button", name="ログイン").click()
    page.get_by_role("heading", name="注文一覧").wait_for()


def export_csv(page) -> Path:
    page.goto(f"{BASE_URL}/orders")
    if "/login" in page.url:
        login(page)
        page.goto(f"{BASE_URL}/orders")

    with page.expect_download(timeout=120_000) as download_info:
        page.get_by_role("button", name="CSVエクスポート").click()
    download = download_info.value

    error = download.failure()
    if error:
        raise RuntimeError(f"ダウンロード失敗: {error}")

    stamp = datetime.now().strftime("%Y%m%d")
    dest = SAVE_DIR / f"{stamp}_{download.suggested_filename}"
    download.save_as(dest)
    return dest


def check_csv(path: Path, min_rows: int = 1):
    df = pd.read_csv(path, encoding="utf-8-sig")
    if len(df) < min_rows:
        raise RuntimeError(f"行数が少なすぎます: {len(df)}行")
    return df


def main() -> int:
    SAVE_DIR.mkdir(parents=True, exist_ok=True)
    STATE_FILE.parent.mkdir(parents=True, exist_ok=True)

    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        context = browser.new_context(
            accept_downloads=True,
            storage_state=STATE_FILE if STATE_FILE.exists() else None,
        )
        context.tracing.start(screenshots=True, snapshots=True)
        page = context.new_page()
        try:
            dest = export_csv(page)
            context.storage_state(path=STATE_FILE)
            STATE_FILE.chmod(0o600)
            context.tracing.stop()
        except Exception as e:
            context.tracing.stop(path=SAVE_DIR / "trace_failed.zip")
            print(f"取得に失敗: {e}", file=sys.stderr)
            return 1
        finally:
            context.close()
            browser.close()

    df = check_csv(dest)
    print(f"保存: {dest}({len(df)}行)")
    return 0


if __name__ == "__main__":
    sys.exit(main())

ブロックごとの説明

  • login():ログイン画面の入力欄を、表示されているラベル名(get_by_label)とボタンの役割(get_by_role)で探します。Playwright公式は、CSSセレクタやXPathより、ユーザーに見えるロール・ラベル・テキストに基づく探し方を推奨しています。クラス名が変わる程度の画面改修なら、この書き方は壊れにくくなります。最後の wait_for() は「注文一覧」という見出しが出るまで待つ処理で、ログイン完了の確認を兼ねています。認証情報はコードに書かず、環境変数から読みます。
  • export_csv() の前半:一覧ページを開き、ログイン画面へ転送されたら(URLに /login が含まれたら)ログインしてから開き直します。保存したログイン状態が切れた日でも、自動で復旧できます。
  • expect_download のブロック:この記事の中心です。待ち受けを登録した状態でボタンを押します。timeout=120_000 は120秒(単位はミリ秒)です。データ量が多くCSVの生成に時間がかかる画面に合わせて調整してください。
  • download.failure():ダウンロードが終わるまで待ち、失敗していればその理由を、成功していれば None を返します。sleepによる監視ループはもう要りません。
  • save_as():サーバーが付けたファイル名(suggested_filename)の先頭に日付を付けて保存します。毎日同じ名前で落ちてくる画面でも、前日分を上書きしません。
  • main() のコンテキスト生成:storage_state は、ログイン後のCookieなどを保存したJSONファイルです。前回の状態があれば読み込むので、毎日ログインし直す必要がありません。このファイルはログイン済みの鍵と同じ扱いになるので、権限を600にしています。
  • トレース:context.tracing はPlaywright組み込みの記録機能で、各操作の画面とDOMを残します。成功時は破棄し、失敗時だけ trace_failed.zip に保存します。headlessで見えない画面で何が起きたかを、あとから playwright show-trace trace_failed.zip で再生できます。
  • 終了コード:失敗時は 1 を返します。これは次の節で、systemdが失敗を記録するために使います。

読み込み完了を networkidle で待つ書き方は使っていません。理由はPlaywright networkidle非推奨|26年10月にまとめています。

落ちてきたCSVをpandas 3.0で点検する:0行のファイルを成功扱いにしない

ダウンロードが成功しても、中身が正しいとは限りません。ヘッダー行しかないファイルや、セッション切れでエラーページのHTMLがCSVとして保存されたファイルでも、ダウンロード自体は成功として扱われます。そのため check_csv() で、少なくとも1行あるかを確かめています。

文字コードの指定

国内の管理画面には、Excel向けにShift_JIS(Pythonでは cp932)で出力するものと、BOM付きUTF-8で出力するものがあります。utf-8-sig で読めずに UnicodeDecodeError になる画面では、encoding="cp932" に変えてください。どちらになるかは画面ごとに違うので、最初に1回手で落としたファイルで確認します。

pandas 3.0で変わった点

  • 文字列の列は専用の str dtype として読み込まれます。列が文字列かを判定するときは、df[c].dtype == object ではなく pd.api.types.is_string_dtype(df[c]) を使います。
  • Copy-on-Writeが既定で有効になりました。一部の列を切り出して加工しても、元のDataFrameは変わりません。
  • DataFrame.applymap() は削除されました。代わりに DataFrame.map() を使います。

点検の基準は、毎日の行数が大きく変わらない画面なら「前日の半分未満なら失敗」のように厳しくしてもかまいません。黙って空のファイルが溜まっていく状態が一番気づきにくいので、異常を終了コードで外に知らせる形にしておきます。

systemd timerで毎日headless実行する:止まる条件と置き場所の選び方

ユニットファイル

ユーザー単位のsystemdで動かします。まず ~/.config/systemd/user/csv-export.service を作ります。

[Unit]
Description=管理画面のCSVを取得する

[Service]
Type=oneshot
WorkingDirectory=/home/username/csv-export
EnvironmentFile=/home/username/.config/csv-export/env
ExecStart=/home/username/csv-export/.venv/bin/python export_csv.py

次に、同じ場所に csv-export.timer を作ります。

[Unit]
Description=CSV取得を毎朝実行する

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

[Install]
WantedBy=timers.target
  • EnvironmentFile:EXPORT_USER=... と EXPORT_PASSWORD=... を書いたファイルを読み込みます。chmod 600 にして、自分以外が読めないようにします。
  • ExecStart:venvのPythonを絶対パスで指定します。systemdはシェルの設定を読まないので、activate に頼る書き方は動きません。
  • Persistent=true:サーバーが止まっていた時間に実行予定があった場合、次の起動時にまとめて実行します。
  • RandomizedDelaySec=300:実行開始を最大5分ずらします。毎日同じ秒にアクセスが集中するのを避けるためです。
systemctl --user daemon-reload
systemctl --user enable --now csv-export.timer
sudo loginctl enable-linger username      # ログアウト中もuser timerを動かす
systemctl --user list-timers              # 次回実行時刻の確認
journalctl --user -u csv-export.service   # 実行ログの確認

enable-linger を入れ忘れると、SSHからログアウトした時点でuser timerが止まります。スクリプトが 1 を返した日は、systemctl --user status csv-export.service に failed と記録されます。OnFailure= で通知用のユニットを呼べば、失敗に気づけます。

どこで動かすか:それぞれが止まる条件

置き場所

向いている条件

止まる条件

手元のPC(launchd・タスクスケジューラ)

まず1〜2週間試したい

スリープ中・電源オフ・持ち出し中は動かない。macOSのlaunchdはスリープ中の予定を復帰後に回すが、時刻はずれる

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

requestsだけで取れる処理

root権限が無く --with-deps でOSライブラリを入れられない場合、Chromiumが起動しない。メモリやプロセスの実行時間の制限に当たると途中で終了される。可否は契約プランの仕様で確認が要る

VPS・自宅の常時稼働機(systemd timer)

ブラウザを毎日起動する処理

OS更新後の再起動忘れ、ディスクの容量不足、pip 更新後の playwright install 忘れ。電源断では Persistent=true が起動後に取り返す

Playwrightでheadlessブラウザを毎日起動するなら、root権限があり、常時稼働する環境が前提になります。GitHub Actionsのスケジュール実行という選択肢もあり、GitHub Actions cronスクレイピング|26年10月で扱っています。ただし管理画面の認証情報を外部のサービスへ預けることになるので、社内システムなどでは避けた方が無難です。

管理画面の自動ダウンロードで守ること:利用規約・頻度・認証

robots.txtは検索エンジンなどのクローラー向けの取り決めで、Googleの仕様はRFC 9309に準拠しています。ログインして使う管理画面は、robots.txtよりもそのサービスの利用規約が判断の基準になります。自分のアカウントのデータであっても、自動操作を禁止しているサービスはあります。

  • 公式の手段を先に探す:エクスポートAPIや、CSVを定期的にメールで送る機能がある場合は、そちらを使います。画面の改修で壊れることもありません。
  • 頻度は1日1回で足りるなら1日1回:エクスポートは、相手のサーバーでCSVを生成する重い処理です。並列で回したり、数分おきに叩いたりしないでください。
  • ログインは最小限に:storage_state でセッションを使い回し、ログイン回数を減らします。短時間にログインを繰り返すと、不正アクセスを疑われてアカウントがロックされることがあります。
  • 二段階認証やCAPTCHAは回避しない:これらが出る画面は、自動操作を想定していないという意思表示です。突破する方法を探すのではなく、APIの利用やサービス提供元への相談に切り替えます。
  • 権限の範囲を守る:取得するのは、自分の権限で手作業でも落とせるデータだけにします。不正アクセス禁止法や著作権法など、国内の法令にも従ってください。

関連する選択肢

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

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

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

Playwright expect_download で毎日CSVを取り続けるためのまとめ

管理画面のCSVエクスポートは、Playwrightの with page.expect_download() の中でボタンを押し、download.failure() で完了と成否を確かめてから、download.save_as() で保存する書き方が今の基本です。フォルダの監視やsleepは要らず、headlessのままでも動きます。保存はコンテキストを閉じる前に行ってください。

毎日動かし続けるには、次の3点を押さえます。storage_state でログイン状態を使い回すこと。0行のファイルを成功扱いにせず、終了コードで失敗を知らせること。失敗した日だけトレースを残すこと。

実行場所は、ブラウザを起動できてroot権限のある常時稼働のサーバーにし、systemd timerを Persistent=true と enable-linger を付けて登録します。Playwrightを更新したら playwright install もやり直し、APIや公式の定期送信機能があればそちらを優先してください。