手元のPCでは動いたPlaywrightのスクリプトを、画面の無いUbuntu 24.04サーバーに載せた途端に起動しない。cronに登録したら翌朝は何も取れていなかった。そういう場面は多いと思います。Playwright を systemd timer で定期実行するなら、今は playwright install --with-deps chromium で依存ごと入れ、ヘッドレス専用の chromium-headless-shell で動かし、Persistent=true 付きのタイマーで登録するのが一番止まりにくい形です。この記事では、そのコードと登録手順を順に説明します。

以前のcron+xvfb-runから、systemd timer+chromium-headless-shellへ

画面の無いサーバーでブラウザ自動化を動かす方法は、ここ数年で定番が変わりました。まず新旧を並べておきます。

項目

以前よく見た書き方

今の書き方

ブラウザの導入

playwright install chromium だけ実行し、足りない共有ライブラリを apt で1つずつ探して入れる

playwright install --with-deps chromium で、ブラウザ本体とOS側の依存パッケージをまとめて入れる

画面が無い問題

xvfb-run(仮想ディスプレイ)で包んで、画面付きのブラウザを無理やり起動する

headless=True で起動する。ヘッドレス専用ビルドの chromium-headless-shell が使われるので仮想ディスプレイは不要

定期実行

crontab に1行書く。PATHも仮想環境も効かず、ログはどこにも残らない

systemd の service と timer を書く。実行ログは journalctl に残り、止まっていた間の分は Persistent=true で起動時に取り返す

失敗の検知

出力ファイルが空になって初めて気づく

スクリプトが失敗時に終了コード1を返し、systemd 側で「failed」として見える

依存パッケージを入れずに起動すると、Playwright は次のような警告を出して止まります。--with-deps が必要な理由はこれです。

Host system is missing dependencies to run browsers.

ブラウザの起動部分はサーバーでも手元でも同じコードで動くので、locator を使った今の書き方そのものは Playwrightのlocator移行2026年9月版 にまとめてあります。この記事では「サーバーに置いて毎日動かす」部分に絞ります。

Ubuntu 24.04 に Playwright と chromium-headless-shell を入れる手順

実行環境とPythonのバージョン

  • OS:Ubuntu 24.04(サーバー版・デスクトップ無しを想定)
  • Python:3.11以降。2026年9月30日〜10月1日に公開された 3.10.22 をもって Python 3.10系列はサポート終了(EOL)になったため、新しく組むなら3.10は選ばないでください。手元の版は python3 -V で確認できます
  • パッケージ:playwright(Python用のライブラリ)だけ。CSVの書き出しは標準ライブラリで行います

Playwright のバージョンはここでは固定しません。ライブラリとブラウザのビルドは組で配布されているため、ライブラリを上げたら必ず playwright install もやり直す、という運用だけ守れば十分です。入っている版は pip show playwright で確認できます。

仮想環境を作ってブラウザを入れる

sudo apt update
sudo apt install -y python3-venv

mkdir -p /home/username/scraper/data
cd /home/username/scraper
python3 -m venv .venv
.venv/bin/pip install playwright

# ブラウザ本体+OS側の依存パッケージをまとめて入れる
.venv/bin/playwright install --with-deps chromium

ブロックごとに何をしているかを説明します。

  • 1〜2行目:仮想環境を作るための python3-venv を入れます。Ubuntu 24.04 ではシステムのPythonに直接 pip install することが制限されているので、仮想環境を使う前提にします
  • 4〜7行目:作業ディレクトリと、その中の .venv に専用のPython環境を作り、Playwright を入れます。後で systemd から .venv/bin/python を絶対パスで呼ぶので、activate は不要です
  • 最終行:--with-deps を付けると、ブラウザのダウンロードに加えて、動作に必要な共有ライブラリやフォントを apt で入れます。この部分だけ管理者権限が要るので、途中で sudo のパスワードを聞かれます

ここで注意したいのは、コマンド全体を sudo で実行しないことです。ブラウザ本体はコマンドを実行したユーザーのキャッシュ(/home/username/.cache/ms-playwright/)に入ります。sudo で実行すると root のキャッシュに入ってしまい、あとで一般ユーザーとして動くタイマーからは「ブラウザが見つからない」状態になります。

入ったものは次で確認できます。chromium 本体とは別に、headless-shell と名の付いたディレクトリがあれば、ヘッドレス専用ビルドも入っています。

ls /home/username/.cache/ms-playwright/

オプションの細かい差(ヘッドレス専用ビルドだけを入れる指定など)はバージョンで変わることがあるので、使う前に .venv/bin/playwright install --help で手元の版が受け付けるものを確認してください。

毎日動かしても止まらない Playwright スクリプトの書き方

手元で動くスクリプトをそのまま載せると、サーバーでは「失敗しても気づけない」「固まったまま終わらない」の2つで止まります。次のコードはその2つを最初から潰した形です。対象は架空の一覧ページ https://example.com/news/ で、記事のタイトルとURLをCSVに保存します。

#!/usr/bin/env python3
import csv
import sys
from datetime import date
from pathlib import Path
from urllib import robotparser

from playwright.sync_api import sync_playwright, TimeoutError as PWTimeout

BASE = "https://example.com"
TARGET = f"{BASE}/news/"
OUT_DIR = Path("/home/username/scraper/data")


def allowed(url):
    rp = robotparser.RobotFileParser(f"{BASE}/robots.txt")
    rp.read()
    return rp.can_fetch("*", url)


def scrape():
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        try:
            page = browser.new_page()
            page.set_default_timeout(30_000)
            page.goto(TARGET, wait_until="domcontentloaded")

            links = page.locator("article h2 a")
            links.first.wait_for()

            rows = []
            for a in links.all():
                rows.append({
                    "title": a.inner_text().strip(),
                    "url": a.get_attribute("href"),
                })
            return rows
        finally:
            browser.close()


def save(rows):
    OUT_DIR.mkdir(parents=True, exist_ok=True)
    path = OUT_DIR / f"news_{date.today():%Y%m%d}.csv"
    with path.open("w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=["title", "url"])
        writer.writeheader()
        writer.writerows(rows)
    return path


def main():
    if not allowed(TARGET):
        print("robots.txt で許可されていないため中止します", file=sys.stderr)
        return 1
    try:
        rows = scrape()
    except PWTimeout:
        print("ページの読み込みが時間内に終わりませんでした", file=sys.stderr)
        return 1
    if not rows:
        print("0件でした。ページの構造が変わった可能性があります", file=sys.stderr)
        return 1
    path = save(rows)
    print(f"{len(rows)}件を保存しました: {path}")
    return 0


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

ブロックごとの説明

  • allowed():標準ライブラリの urllib.robotparser で取得先の robots.txt を読み、対象URLへのアクセスが許可されているかを毎回確かめます。サイト側の方針は後から変わることがあるので、初回だけでなく毎日見るのが大事です
  • p.chromium.launch(headless=True):ヘッドレスで起動します。画面の無いサーバーでも xvfb-run は要りません
  • set_default_timeout(30_000):すべての待機の上限を30秒にします。上限が無いと、相手のサーバーが応答しないときにスクリプトが終わらず、翌日の実行まで巻き込みます
  • links.first.wait_for():一覧の最初の要素が表示されるまで待ちます。JavaScriptで後から描画されるページでも、要素が出るのを待ってから読むので取りこぼしません。time.sleep() で決め打ちに待つより速く、確実です
  • try / finally: browser.close():途中で例外が出てもブラウザを必ず閉じます。閉じ忘れたブラウザのプロセスが毎日1つずつ残ると、メモリの少ないサーバーではいずれ動かなくなります
  • save():日付入りのファイル名でCSVに保存します。encoding="utf-8" は明示しておきます。Python 3.15 でデフォルトのエンコーディングがUTF-8になる(PEP 686)予定ですが、3.11〜3.14で動かす間は明示しないと環境によって文字化けします
  • main() の戻り値:robots.txt で拒否された・タイムアウトした・0件だった、のいずれも 終了コード1 で終わります。ここが一番重要で、0件のまま「正常終了」すると systemd 側からは成功に見え、セレクタが壊れていても何週間も気づけません

まずは手元と同じように、サーバー上で直接実行して動くことを確かめます。

/home/username/scraper/.venv/bin/python /home/username/scraper/scrape.py
echo $?

2行目で 0 が出れば成功です。ここで動かないものは、タイマーに登録しても動きません。

systemd timer の Persistent=true で、止まっていた日の分も取り返す

service と timer の2ファイルを書く

systemd の定期実行は、「何を実行するか」を書く service と、「いつ実行するか」を書く timer の2ファイルで構成します。root の権限が要らない処理なので、一般ユーザーのユニット(ユーザーユニット)として置きます。こうするとブラウザのキャッシュも同じユーザーのものが使われ、前の節で書いた「ブラウザが見つからない」問題が起きません。

/home/username/.config/systemd/user/scraper.service

[Unit]
Description=Playwright news scraper

[Service]
Type=oneshot
WorkingDirectory=/home/username/scraper
ExecStart=/home/username/scraper/.venv/bin/python /home/username/scraper/scrape.py
TimeoutStartSec=15min

/home/username/.config/systemd/user/scraper.timer

[Unit]
Description=Run Playwright news scraper daily

[Timer]
OnCalendar=*-*-* 06:00:00
Persistent=true
RandomizedDelaySec=10min

[Install]
WantedBy=timers.target

各行が何をしているか

設定

意味

Type=oneshot

1回実行して終わる処理であることを示します。常駐しないスクレイピングにはこれを使います

ExecStart=...

仮想環境のPythonを絶対パスで呼びます。cron で動かなかった原因の多くは PATH と仮想環境なので、絶対パスで書けば activate も PATH の設定も不要です

TimeoutStartSec=15min

スクリプト内のタイムアウトをすり抜けて固まった場合の最後の安全装置です。15分で強制終了し、失敗として記録されます

OnCalendar=*-*-* 06:00:00

毎日6:00に実行します

Persistent=true

電源断やメンテナンスで6:00を逃しても、次に起動したときに1回分を実行します。cron には無い機能で、「止まっていた日のデータが丸ごと欠ける」のを防ぎます

RandomizedDelaySec=10min

実行時刻を最大10分ずらします。毎日ぴったり同じ時刻に取得先へアクセスが集中するのを避けるためです

登録して、ログアウト後も動くようにする

systemctl --user daemon-reload
systemctl --user enable --now scraper.timer

# ログアウトしてもユーザーユニットが動き続けるようにする(1回だけ)
sudo loginctl enable-linger username

# 次回の実行予定を確認
systemctl --user list-timers

# タイマーを待たずに1回実行して、ログを確認
systemctl --user start scraper.service
journalctl --user -u scraper.service -n 30
  • daemon-reload:書いたユニットファイルを systemd に読み込ませます。ファイルを直したら毎回必要です
  • enable --now:再起動後も自動でタイマーが有効になるよう登録し、同時に今すぐ有効にします
  • enable-linger:これを入れないと、SSHからログアウトした時点でユーザーユニットが止まります。「手で試すと動くのに、翌朝は動いていない」の典型的な原因です
  • start scraper.service:6:00を待たずに本番と同じ条件で実行します。手で実行したときとは環境変数などが違うので、登録したら必ずこの経路でも1回試してください
  • journalctl:スクリプトの print() の出力も、エラーの出力もここに残ります。終了コード1で終わった回は systemctl --user status scraper.service で failed と表示されます

Seleniumで同じ構成を組んでいる場合も、service と timer の部分はそのまま使えます。ヘッドレスの指定だけはSelenium側の書き方が変わっているので、Selenium headless=new移行2026年9月版 を合わせて確認してください。

取得先サイトへの配慮:robots.txt・利用規約・アクセス間隔

毎日自動で動くものは、1回の手動実行よりも相手のサーバーに与える影響が積み重なります。次の点は必ず守ってください。

  • robots.txt を毎回確認する:上のコードの allowed() のように、実行のたびに確かめます。拒否されていたら取得せずに終了します
  • 利用規約を読む:大手ECサイトなど、ロボットによるデータ収集を規約で認めていないサイトは多くあります。規約で禁止されている場合は取得しないでください。ログインが必要なページの取得は、不正競争防止法上のリスクも高まります
  • アクセスは必要最小限に:1日1回・1ページで足りるなら、それ以上取らないでください。複数ページを辿る場合は1ページごとに数秒以上の間隔を空けます。相手のサーバーに過度な負荷をかけて障害を起こせば、業務妨害罪に問われた事例(岡崎市立中央図書館事件)もあります
  • 公式の提供手段があるならそちらを使う:APIやRSSが用意されているなら、ブラウザで画面を読むより相手への負荷も少なく、壊れにくくなります。ただし提供手段そのものが終わることもあります。Reddit は2026年9月30日にRSSフィードのサポート終了(2026年11月13日終了予定)を発表し、2027年1月12日以降は公開APIを登録済みのアプリに限定します。定期実行を組んだら、取得先の告知も定期的に確認してください
  • 個人情報と著作物の扱い:公開情報でも、個人情報を含むデータの取得や第三者提供には本人同意が必要になる場合があります。取得した文章をそのまま公開・掲載することも、著作権法30条の4の範囲外です

また、bot対策(CAPTCHAやアクセス制限)で弾かれた場合は、それを回避しようとせず取得をやめてください。相手が「自動アクセスを受け付けない」と意思表示している状態です。

手元のPC・共有レンタルサーバーのcron・VPS:Playwrightを毎日動かし続けられる場所

ここまでのコードが書けても、「どこで動かすか」を誤ると止まります。Playwright はブラウザ本体とOS側の依存パッケージを必要とするので、requests だけのスクリプトより置き場所の制約が強くなります。

置き場所

Playwrightが動くか

止まる条件

手元のPC(Mac・Windows・Linux)

動く

スリープ中・電源オフの間は実行されない。macOS の launchd も Windows のタスクスケジューラも、閉じている間の分は基本的に飛ぶ。持ち歩くノートPCでは毎日の実行は保証できない

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

多くの場合動かない

root権限が無いため --with-deps の依存パッケージを入れられない。メモリやプロセス数・実行時間の制限でブラウザ起動が強制終了されることもある。requests+BeautifulSoup で取れるページなら、ここが一番安い選択肢

VPS・自宅の常時稼働サーバー

動く(この記事の構成)

電源断・回線断・ディスク満杯。電源断は Persistent=true で取り返せるが、ディスクは出力CSVが溜まり続けるので、古いファイルの削除まで含めて組む。メモリが少ない機種ではブラウザ起動で詰まりやすい

判断の目安は次のとおりです。

  1. 取得したいページが requests で取れる(HTMLにデータが入っている)なら、Playwright を使わず共有レンタルサーバーのcronで十分です
  2. JavaScriptで描画されるページで Playwright が必要なら、root権限のあるVPSか、常時電源の入ったLinux機に置き、この記事の service+timer で登録します
  3. 手元のPCは「毎日」の用途には向きません。試作と、手で実行する確認用に使います

関連する選択肢

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

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

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

まとめ

手元で動いた Playwright のスクリプトを画面の無い Ubuntu 24.04 サーバーで毎日動かすには、以前の「cron+xvfb-run」ではなく、今は次の形にします。ブラウザは一般ユーザーで playwright install --with-deps chromium を実行して依存ごと入れ、headless=True で chromium-headless-shell を使います。スクリプトは全待機にタイムアウトを設け、ブラウザを必ず閉じ、0件やタイムアウトを終了コード1で返して失敗を見えるようにします。定期実行はユーザーユニットの service と timer で登録し、Persistent=true で止まっていた日の分を取り返し、loginctl enable-linger でログアウト後も動かし続けます。Python 3.10 は2026年9月30日でサポート終了なので、3.11以降で組んでください。置き場所は、Playwright が必要ならroot権限のあるVPSか常時稼働のLinux機、requests で足りるなら共有サーバーのcronが現実的です。そして毎日動くものほど、robots.txt・利用規約・アクセス間隔を毎回守る作りにしておくことが、長く動かし続ける前提になります。