Playwrightで書いた定期実行スクリプトが、毎回ログイン画面にIDとパスワードを入力していませんか。毎朝ログイン通知メールが届き、ログイン画面の文言が変わるだけで全体が止まり、相手のサーバーには毎回不要な認証リクエストを送ることになります。今はPlaywrightの storage_state でログイン状態をファイルに保存し、次回からはそのファイルを読み込んで始める書き方が基本です。この記事では、セッション切れを検知したときだけ再ログインする実装と、systemd timer・cron・launchdのどこで動かせば止まらないかまでを、動くコードで解説します。

Playwright v1.62での storage_state:毎回ログインする書き方との違い

storage_state が保存するもの

storage_state は、ブラウザのコンテキスト(Cookieなどを共有する1つのブラウザセッション。シークレットウィンドウ1枚に相当)が持っているCookieとlocalStorageをJSONファイルに書き出す機能です。ログイン済みのコンテキストから書き出して、次回は browser.new_context(storage_state=...) で読み込めば、ログイン済みの状態から始められます。

公式ドキュメントでは、sessionStorageは保存の対象外と説明されています。ログイン状態をsessionStorageだけで持つサイトでは、この方法では再ログインを避けられません。オプションは版によって増えることがあるため、使っている版の公式ドキュメント(Python版の BrowserContext.storage_state)で引数を確認してください。

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

以前によく見られたのは、次のように毎回ログインフォームから入る書き方です。

# 以前の書き方:実行のたびにログインする
page.goto("https://example.com/login")
page.fill("#login_id", os.environ["LOGIN_ID"])
page.fill("#password", os.environ["LOGIN_PASSWORD"])
page.click("button[type=submit]")
page.goto("https://example.com/reports/daily")

今は、保存しておいた状態を読み込み、目的のページを開いてみて、ログイン画面に戻されたときだけログインするという順番で書きます。

項目

毎回ログイン(以前)

storage_state を使う(今)

ログイン回数

実行のたびに1回

セッションが切れたときだけ

相手サーバーへのリクエスト

ログイン画面+認証が毎回発生

目的のページだけ

ログイン画面の変更

毎回の実行が止まる

再ログインが必要な日だけ影響

ログイン通知メール・不審ログイン判定

毎日発生しうる

大きく減る

管理するもの

ID・パスワード

ID・パスワード+状態ファイル(パスワード同等に扱う)

調査時点の最新版はPlaywright v1.62(2026年7月リリース)です。v1.62ではChromiumの実行が Chrome for Testing ビルドに変わり、Headedモードでは chrome、Headlessモードでは chrome-headless-shell が使われます。storage_state の使い方自体はこの変更の影響を受けません。

storage_state の保存・読込とセッション切れ検知を組み込んだ完成コード

実行環境

  • Python:3.11以降を前提にします(加工に使う pandas 3.0 が Python 3.11以降を必須としているため。Playwright単体の対応版は pip show playwright と公式ドキュメントで確認してください)
  • パッケージ:pip install playwright pandas
  • ブラウザ本体:playwright install chromium(Linuxで依存ライブラリが足りない場合は playwright install --with-deps chromium)
  • 入っている版の確認:python3 -V、pip show playwright pandas

題材は「自分のアカウントで入れる管理画面から、日次レポートの表を取得してCSVに保存する」処理です。URLは例として example.com を使っています。

import json
import os
import sys
import tempfile
from pathlib import Path

import pandas as pd
from playwright.sync_api import sync_playwright

BASE = "https://example.com"
LOGIN_URL = f"{BASE}/login"
TARGET_URL = f"{BASE}/reports/daily"
WORKDIR = Path("/home/username/scraper")
STATE = WORKDIR / "state" / "auth.json"
OUT = WORKDIR / "data" / "daily_report.csv"


def load_state():
    """保存済みの状態を読む。無い・壊れている場合は None"""
    if not STATE.exists():
        return None
    try:
        return json.loads(STATE.read_text())
    except json.JSONDecodeError:
        return None


def save_state(context):
    """状態を一時ファイルに書いてから差し替える"""
    STATE.parent.mkdir(parents=True, exist_ok=True)
    data = context.storage_state()
    # mkstemp は所有者だけが読める権限(600)でファイルを作る
    fd, tmp = tempfile.mkstemp(dir=STATE.parent, suffix=".tmp")
    with os.fdopen(fd, "w") as f:
        json.dump(data, f)
    os.replace(tmp, STATE)


def is_logged_out(page):
    """ログイン画面に戻されていれば True"""
    if page.url.startswith(LOGIN_URL):
        return True
    return page.get_by_label("パスワード").count() > 0


def login(page):
    page.goto(LOGIN_URL)
    page.get_by_label("ログインID").fill(os.environ["LOGIN_ID"])
    page.get_by_label("パスワード").fill(os.environ["LOGIN_PASSWORD"])
    page.get_by_role("button", name="ログイン").click()
    # ログイン後の画面に移るまで待つ。来なければ例外で止まる
    page.wait_for_url(f"{BASE}/dashboard**", timeout=30_000)


def fetch(page):
    """目的のページを開いて表を取る。ログアウト状態なら None"""
    page.goto(TARGET_URL)
    if is_logged_out(page):
        return None
    table = page.get_by_role("table")
    headers = table.locator("thead th").all_inner_texts()
    rows = [
        row.locator("td").all_inner_texts()
        for row in table.locator("tbody tr").all()
    ]
    return pd.DataFrame(rows, columns=headers)


def main():
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        state = load_state()
        context = browser.new_context(storage_state=state) if state else browser.new_context()
        page = context.new_page()

        df = fetch(page)
        if df is None:
            print("セッション切れ:再ログインします", file=sys.stderr)
            login(page)
            df = fetch(page)
            if df is None:
                print("再ログイン後もログイン画面に戻されました", file=sys.stderr)
                browser.close()
                sys.exit(2)

        # Cookie の有効期限が延長されることがあるので、成功時は毎回保存し直す
        save_state(context)
        browser.close()

    OUT.parent.mkdir(parents=True, exist_ok=True)
    df.to_csv(OUT, index=False)
    print(f"{len(df)} 行を保存しました")


if __name__ == "__main__":
    main()

ブロックごとの説明

  • load_state():保存済みのJSONを読みます。new_context(storage_state=...) はファイルパスだけでなく辞書も受け取れるので、先に自分で読み込んで壊れていたら無いものとして扱うようにしています。書き込み途中で電源が落ちてJSONが壊れると、パスを直接渡す書き方では起動時に毎回例外で止まるためです。
  • save_state():context.storage_state() を引数なしで呼ぶと辞書が返ります。これを一時ファイルに書いてから os.replace() で差し替えるので、書きかけのファイルが本番の状態ファイルとして残ることがありません。storage_state(path=...) で直接書き出す方法もありますが、定期実行ではこの差し替え方式のほうが安全です。
  • is_logged_out():セッション切れの検知です。多くのサイトはセッションが切れるとログイン画面へリダイレクト(自動転送)するので、まずURLで判定します。転送せずにその場でログインフォームを出すサイトもあるため、パスワード欄の有無も確かめています。「ログインできているか」を表の中身で判定しないのがポイントで、表が空のときとログアウトのときを区別できなくなるためです。
  • login():ID・パスワードはコードに書かず環境変数から読みます。ロケーター(画面上の要素を指定する方法)は、調査結果で推奨されている get_by_label()・get_by_role() を使っています。CSSのid名よりも画面の改修に強い書き方です。最後の wait_for_url() でログイン後の画面に移ったことを確かめ、30秒で移らなければ例外で止めます。
  • fetch():目的のページを開き、ログアウト状態なら None を返します。表は見出し行と各行のセルを文字列として取り出し、pandasのDataFrameにしています。
  • main():「状態を読む → 取得を試す → ダメなら1回だけログインして再取得 → 成功したら状態を保存」という流れです。再ログインは1回だけにしています。ループで何度もログインを試すと、相手側でアカウントがロックされたり、不正アクセスの試行と区別がつかなくなったりします。再ログインしても入れなければ終了コード2で終わらせ、スケジューラ側に「失敗」として記録させます。

二段階認証・CAPTCHAが出たときは止める

ログイン時に二段階認証やCAPTCHA(画像認証)が出るサイトでは、wait_for_url() がタイムアウトして例外で止まります。これは正しい動作です。これらは自動化を止めるために置かれている仕組みなので、回避するコードを書くのではなく、人が一度ブラウザでログインして状態ファイルを作り直す運用にしてください。手動で状態を作るには、headless=False で起動して自分でログインし、save_state() を呼ぶだけの小さなスクリプトを別に用意しておくと楽です。

状態ファイルはパスワードと同じ扱いにする

auth.json にはセッションCookieがそのまま入っています。このファイルを持っている人は、パスワードを知らなくてもあなたとしてログインできます。

  • 権限は 600(所有者だけが読み書きできる)にする。上のコードは mkstemp で作るので自動的にそうなります
  • Gitの管理から外す(.gitignore に state/ を書く)
  • ID・パスワードを書いた .env も同じく 600 にして、リポジトリに入れない
  • 別のマシンへコピーして使い回さない。共有したくなったら、そのマシンで改めてログインする

ログイン後に画面がAPIからJSONを受け取って表を描いているサイトなら、表をDOMから読むより、通信の応答そのものを受け取るほうが確実です。その書き方は expect_responseでJSON取得|2026年10月版 で解説しています。

ログインが必要なページを自動取得するときの配慮:規約・robots.txt・アクセス間隔

対象は「自分に権限があり、自動取得が禁じられていないもの」だけ

日本にはスクレイピングを一律に禁止する法律はなく、総務省も消費者物価指数の調査にWebスクレイピングを使っています。ただしログインが必要なページは非公開情報にあたり、扱いを誤ると利用規約違反や不正アクセス禁止法の問題になりえます。storage_state を使う前に、次を確認してください。

  • ログインに使うのは自分のアカウントで、他人のID・パスワードやCookieは使わない
  • 利用規約で、自動化ツールによるアクセスやデータの取得が禁じられていないか
  • 公式APIやCSVエクスポート機能が用意されていないか。あるならそちらを優先する(Amazonなどの主要ECサイトは規約でスクレイピングを制限しつつ、公式APIを提供しています)
  • 取得したデータを外部に再配布・販売しない。社内での分析・業務効率化に留める(著作権法第47条の7「情報解析のための複製等」が想定する範囲)

robots.txt とアクセス間隔

  • https://example.com/robots.txt を開き、取得するパスが Disallow になっていないか確かめる
  • このスクリプトが開くページは、通常1回の実行で1ページ(再ログイン時でも数ページ)です。実行は1日1回など、業務に必要な最小の頻度に絞る
  • 複数ページを巡回するように拡張するなら、ページ間に数秒の待ち時間を入れ、並列で開かない
  • ブロックされたときに、User-Agentの偽装やプロキシの切り替えで突破しようとしない

Cloudflareの2026年9月15日の変更で止まることがある

Cloudflareは2026年9月15日にAIトラフィックのデフォルト設定を変更し、広告が掲載されているページについて、新規顧客・新規サイト・無料ティアのユーザーに対して2種類のボットを自動でブロックするようになりました。相手のサイトがこの設定の対象になると、昨日まで動いていたスクリプトが急にブロックされることがあります。これはサイト運営者側の意思表示なので、回避せずに、公式APIの有無を確かめるか、運営者に問い合わせてください。

systemd timer・cron・launchd で毎日動かし続ける設定と、それぞれが止まる条件

どこで動かすかの判断表

実行場所

向いている条件

止まる条件

手元のMac(launchd)

1日1回で、その時刻にMacを使っていることが多い

電源オフの日は実行されない。スリープ中に時刻が来た分は、復帰時にまとめて1回だけ実行される(時刻がずれる)

手元のLinux PC(systemd timer)

常時電源を入れておけるPCがある

停電・電源断。スリープ設定が残っていると眠っている間は動かない

共有レンタルサーバー(cron)

requests+BeautifulSoupで足りる軽い処理

ヘッドレスブラウザが使えない、またはメモリ・プロセス数・実行時間の上限で強制終了される場合がある。Chromiumが起動できるかは契約前に確認が要る

VPS(systemd timer)

Playwrightのようにブラウザを起動する処理を毎日確実に動かしたい

メモリ不足でブラウザが落ちる。OS更新後の依存ライブラリ不足(playwright install --with-deps chromium の再実行で戻る)

Playwrightはブラウザ本体を起動するので、止めずに動かすならVPSか常時稼働のLinux機でsystemd timerが第一候補です。どのスケジューラでも共通して効くのは次の3点です。

  • flock で二重起動を防ぐ:前の回が長引いている間に次の回が始まると、2つのプロセスが同じ auth.json を書き換えます
  • ブラウザ本体は実行するユーザーでインストールする:playwright install はユーザーごとのキャッシュ(Linuxでは既定で ~/.cache/ms-playwright)に入れるので、別ユーザー(rootのcronなど)から動かすとブラウザが見つかりません
  • 仮想環境のPythonを絶対パスで指定する:スケジューラはログイン時のPATHを引き継ぎません

systemd timer(VPS・常時稼働のLinux)

~/.config/systemd/user/report-fetch.service:

[Unit]
Description=Fetch daily report with Playwright

[Service]
Type=oneshot
WorkingDirectory=/home/username/scraper
EnvironmentFile=/home/username/scraper/.env
ExecStart=/usr/bin/flock -n /home/username/scraper/.lock /home/username/scraper/.venv/bin/python fetch_report.py

~/.config/systemd/user/report-fetch.timer:

[Unit]
Description=Run report-fetch every morning

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

[Install]
WantedBy=timers.target
systemctl --user daemon-reload
systemctl --user enable --now report-fetch.timer
loginctl enable-linger username      # ログアウト中もユーザーのタイマーを動かす
systemctl --user list-timers         # 次回の実行時刻を確認
journalctl --user -u report-fetch.service -n 50   # 直近のログ
  • Type=oneshot:1回走って終わる処理であることを示します
  • EnvironmentFile:LOGIN_ID=... の形で書いた .env を環境変数として渡します
  • Persistent=true:電源が落ちていて逃した回を、起動時に1回実行します
  • RandomizedDelaySec=300:最大5分ずらして起動し、毎日ぴったり同じ時刻にアクセスするのを避けます
  • loginctl enable-linger:これが無いと、SSHからログアウトした時点でユーザーのタイマーも止まります
  • スクリプトが終了コード2で終わると、サービスは failed として記録されます。セッション切れから復旧できなかった日が、ログを読まなくても分かります

cron(共有レンタルサーバー・systemdが使えない環境)

cronは .env を自動では読まないので、読み込んでから起動するラッパーを用意します。/home/username/scraper/run.sh:

#!/bin/sh
cd /home/username/scraper || exit 1
set -a
. ./.env
set +a
exec /usr/bin/flock -n .lock .venv/bin/python fetch_report.py
chmod 700 /home/username/scraper/run.sh
crontab -e
# 毎朝7:30に実行し、出力をログに追記する
30 7 * * * /home/username/scraper/run.sh >> /home/username/scraper/logs/run.log 2>&1

set -a は「以降に定義した変数を自動で環境変数として書き出す」指定で、.env の中身をPythonから os.environ で読めるようにします。cronには逃した回をあとで実行する仕組みが無いので、サーバーが止まっていた日はそのまま飛びます。

launchd(手元のMac)

~/Library/LaunchAgents/com.example.report-fetch.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.example.report-fetch</string>
  <key>ProgramArguments</key>
  <array>
    <string>/Users/username/scraper/run.sh</string>
  </array>
  <key>StartCalendarInterval</key>
  <dict>
    <key>Hour</key><integer>7</integer>
    <key>Minute</key><integer>30</integer>
  </dict>
  <key>StandardOutPath</key>
  <string>/Users/username/scraper/logs/run.log</string>
  <key>StandardErrorPath</key>
  <string>/Users/username/scraper/logs/run.log</string>
</dict>
</plist>
launchctl load ~/Library/LaunchAgents/com.example.report-fetch.plist
launchctl list | grep report-fetch
  • パスワードはplistの EnvironmentVariables に書かず、cronと同じ run.sh から .env を読みます(plistは権限が緩いまま置かれやすいため)
  • macOSには flock コマンドが標準で入っていません。run.sh の flock の行は、Macでは exec .venv/bin/python fetch_report.py に置き換えてください(1日1回なら二重起動はまず起きません)
  • 作業フォルダは ~/Desktop や ~/Documents を避けます。macOSの保護対象フォルダで、launchdから起動したプロセスが読み書きを拒否されることがあります

ログイン不要のページだけを取るなら、ブラウザを起動しない requests の方が軽く、共有サーバーのcronでも動かしやすくなります。その場合も timeout の指定を忘れると定期実行が黙って止まるので、requests timeout忘れ|AIコード2026年10月 もあわせて確認してください。

関連する選択肢

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

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

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

storage_state で定期実行を止めないための要点

毎回ログインしていたPlaywrightのスクリプトは、context.storage_state() で状態を保存し、browser.new_context(storage_state=...) で読み込む形に書き換えると、ログインはセッションが切れた日だけになります。止まらない形にするための要点は、(1) 目的のページを開いてログイン画面に戻されたかでセッション切れを検知し、再ログインは1回だけにする、(2) 状態ファイルは一時ファイル経由で差し替え、権限600でパスワードと同じに扱う、(3) 二段階認証・CAPTCHAは回避せず人が状態を作り直す、(4) 自分のアカウントで、規約が自動取得を禁じていない範囲に限り、公式APIがあれば優先する、の4つです。実行場所は、ブラウザを起動する以上VPSか常時稼働のLinux機でsystemd timer(Persistent=true・flock・enable-linger)が最も止まりにくく、Macのlaunchdは電源オフの日に、共有サーバーのcronはブラウザが起動できない環境で止まります。