Pythonで書いたスクレイパーをMacのlaunchdで毎日動かそうとして、ネットの手順どおり launchctl load を打ったのに、再起動したら止まっていた、あるいは止めたはずのジョブが復活していた。そんな経験はないでしょうか。load/unload は man ページで「レガシー」と位置づけられたまま、今も古い記事に残っています。今は launchctl bootstrap・bootout・enable で登録します。この記事では、plistとコマンドの書き方を示します。あわせて、スリープで止まる条件を踏まえて、cronの使える共有サーバーやVPSへ移すべき場面も整理します。

launchctl load から bootstrap/bootout/enable への置き換え表

launchctl load と unload は、macOS 10.10 以降の man ページで「Legacy subcommands」に分類されています。今も動きますが、2つの状態を区別できません。1つは「いま読み込まれているか」、もう1つは「有効として記録されているか」です。そのため、次のような事故が起きます。

  • unload で止めたつもりでも、plist が ~/Library/LaunchAgents/ に残っている。この場合、次のログイン時に読み直されて復活する
  • 過去に load -w / unload -w を混ぜて使った。その結果、無効の記録だけが残り、load しても動かない

今の書き方では、「読み込み(bootstrap/bootout)」と「有効・無効の記録(enable/disable)」を別のコマンドで操作します。対象は ドメイン で指定します。ドメインとは、どのユーザーの、どの範囲で動かすかの区分のことです。ログイン中のユーザーなら gui/$(id -u) です。

やりたいこと

以前の書き方

今の書き方

読み込んで動かす

launchctl load ファイル

launchctl bootstrap gui/$(id -u) ファイル

止めて外す

launchctl unload ファイル

launchctl bootout gui/$(id -u)/ラベル

恒久的に有効にする

launchctl load -w

launchctl enable + bootstrap

恒久的に無効にする

launchctl unload -w

launchctl bootout + disable

状態を確認する

launchctl list | grep ラベル

launchctl print gui/$(id -u)/ラベル

今すぐ1回実行する

launchctl start ラベル

launchctl kickstart -k gui/$(id -u)/ラベル

macOS 26 Tahoe で launchd に新たな破壊的変更が入ったかどうかは、今回の調査では確認できませんでした。自分の環境で使えるサブコマンドは、man launchctl の「LEGACY SUBCOMMANDS」節で確かめてください。

launchd で動かす Python スクレイパー(requests+BeautifulSoup+pandas)

実行環境

  • Python 3.11 以降。Python 3.10 は2026年10月1日に公式サポートが終了しています。移行の手順は Python 3.10 EOL→uv移行 2026年10月版 にまとめています
  • pip install requests beautifulsoup4 pandas。バージョン番号はこの記事では固定しません。入ったものは pip show requests beautifulsoup4 pandas で確認してください
  • 仮想環境は /Users/username/scraper/.venv に作る想定です。launchd は PATH が最小限なので、venv の python を絶対パスで呼ぶのが確実です

スクリプト本体(/Users/username/scraper/scrape.py)

import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from urllib.robotparser import RobotFileParser

import pandas as pd
import requests
from bs4 import BeautifulSoup

BASE = "https://example.com"
TARGET = f"{BASE}/news/"
UA = "nashi-sample-bot/1.0 (+https://example.com/contact)"
OUT = Path("/Users/username/scraper/data/news.csv")


def allowed(url: str) -> bool:
    rp = RobotFileParser(f"{BASE}/robots.txt")
    rp.read()
    return rp.can_fetch(UA, url)


def fetch(url: str) -> str:
    for i in range(3):
        r = requests.get(url, headers={"User-Agent": UA}, timeout=20)
        if r.status_code == 429 or r.status_code >= 500:
            wait = 60 * (2 ** i)
            print(f"status={r.status_code} {wait}秒待って再試行", flush=True)
            time.sleep(wait)
            continue
        r.raise_for_status()
        return r.text
    raise RuntimeError("3回試しても取得できませんでした")


def main() -> int:
    if not allowed(TARGET):
        print("robots.txt で許可されていないため中止", flush=True)
        return 1

    soup = BeautifulSoup(fetch(TARGET), "html.parser")
    now = datetime.now(timezone.utc).isoformat()
    rows = [
        {"fetched_at": now, "title": a.get_text(strip=True), "url": a.get("href")}
        for a in soup.select("article h2 a")
    ]
    if not rows:
        print("0件。HTML構造が変わった可能性があります", flush=True)
        return 1

    OUT.parent.mkdir(parents=True, exist_ok=True)
    pd.DataFrame(rows).to_csv(OUT, mode="a", header=not OUT.exists(), index=False)
    print(f"{now} {len(rows)}件を追記", flush=True)
    return 0


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

ブロックごとの説明

  • 定数部分:取得先・User-Agent・出力先をまとめています。User-Agent には連絡先URLを入れ、何者のアクセスかを相手に分かるようにします。出力先は ~/Desktop や ~/Documents を避けます。これらは macOS の保護対象フォルダです。launchd から起動したプロセスは、許可が無いと Operation not permitted で書き込めません
  • allowed():標準ライブラリの urllib.robotparser で robots.txt を読みます。この UA で取得してよいかを判定し、禁止なら取得自体をしません
  • fetch():timeout=20 を必ず付けます。付けないと、応答が無いときに永久に待ち、次の実行も詰まります。429(アクセス過多)や5xxのときは 60→120→240 秒と間隔を倍にして、3回まで再試行します。相手が「待て」と言っているときは、こちらが引く設計です
  • main():select("article h2 a") は例です。実際のセレクタはサイトごとに書き換えてください。0件のときは異常終了(戻り値1)にします。サイトの構造が変わったときに「正常に0件」と記録されて気づかない、という事態を防ぐためです。時刻は datetime.now(timezone.utc) で取ります。utcnow() を使わない理由は utcnow非推奨とcron時刻ずれ|AIコード2026年10月 を参照してください
  • print(..., flush=True):launchd 経由では標準出力が端末ではないため、Python は出力をまとめて溜めます。その状態で異常終了すると、ログが空になることがあります。後述の plist でも -u(バッファしない)を付けています

再起動後も有効なまま残る plist(StartCalendarInterval・StandardOutPath)

plist の中身(~/Library/LaunchAgents/com.example.scraper.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.scraper</string>

  <key>ProgramArguments</key>
  <array>
    <string>/Users/username/scraper/.venv/bin/python</string>
    <string>-u</string>
    <string>/Users/username/scraper/scrape.py</string>
  </array>

  <key>WorkingDirectory</key>
  <string>/Users/username/scraper</string>

  <key>StartCalendarInterval</key>
  <dict>
    <key>Hour</key>
    <integer>9</integer>
    <key>Minute</key>
    <integer>0</integer>
  </dict>

  <key>StandardOutPath</key>
  <string>/Users/username/scraper/logs/scrape.log</string>
  <key>StandardErrorPath</key>
  <string>/Users/username/scraper/logs/scrape.err.log</string>
</dict>
</plist>
  • Label:ジョブの識別名です。ファイル名と揃えておくと、後のコマンドで迷いません
  • ProgramArguments:コマンドを1語ずつ配列で渡します。~ は展開されないので、すべて絶対パスで書きます
  • StartCalendarInterval:毎日9:00に起動します。曜日を絞るなら Weekday を足します。RunAtLoad は書いていません。書くとログインのたびにも実行されるので、1日1回で良いなら不要です
  • StandardOutPath/StandardErrorPath:print の出力と例外の traceback がここに残ります。logs ディレクトリは事前に作っておきます

登録コマンド

mkdir -p ~/scraper/logs
plutil -lint ~/Library/LaunchAgents/com.example.scraper.plist

launchctl bootout gui/$(id -u)/com.example.scraper 2>/dev/null
launchctl enable gui/$(id -u)/com.example.scraper
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.scraper.plist

launchctl print gui/$(id -u)/com.example.scraper | head -n 40
launchctl kickstart -k gui/$(id -u)/com.example.scraper
  1. plutil -lint で plist の書式を検査します。タグの閉じ忘れがあると、登録の段階で分かりにくいエラーになります
  2. 先に bootout します。既に読み込まれたラベルを再度 bootstrap すると、Bootstrap failed: 5: Input/output error で失敗するためです。plist を書き換えたときも、この順で入れ直します
  3. enable で「有効」を記録します。過去に disable された記録が残っていると読み込めないので、明示的に解除しておきます
  4. bootstrap で読み込みます。plist が ~/Library/LaunchAgents/ にあり、無効の記録が無ければ、再起動後もログイン時に自動で読み込まれます
  5. print の出力で state、runs(実行回数)、last exit code を見ます。kickstart -k で9:00を待たずに1回動かし、ログに件数が出るかを確かめます

止めるときは bootout と disable の両方

launchctl bootout gui/$(id -u)/com.example.scraper
launchctl disable gui/$(id -u)/com.example.scraper
launchctl print-disabled gui/$(id -u) | grep com.example.scraper

bootout だけでは、plist が残っている限り次のログインで復活します。disable は無効を launchd 側に記録するので、再起動しても動きません。完全にやめるなら、plist も LaunchAgents の外へ移します。

同じスクリプトをサーバー側でも動かしている場合は特に注意が必要です。Mac側が黙って復活すると、二重に取得・投稿されます。処理済みの記録はマシンごとにあるため、どちらも「未処理」と判断します。

スリープ・フタ閉じで止まる条件と、cron共有サーバー・VPSへ移す判断

launchd が実行しない(遅れる)条件

  • スリープ中:StartCalendarInterval の時刻にスリープしていた場合は、次に起きたときに実行されます。数日分たまっても、まとめて1回になります(man launchd.plist の記述)。cron は寝ている間の分を飛ばすので、この点は launchd が有利です。ただし実行時刻はずれます
  • フタを閉じたノート型:通常はスリープに入るので、上と同じく「開いたときに実行」になります。9:00ちょうどに取らないと意味が無いデータでは使えません
  • 電源オフ・再起動後に未ログイン:gui/$(id -u) ドメインはログインしている間しか存在しません。電源を切っていた日や、再起動後にログインしていない間は動きません
  • 保護フォルダ:先述のとおり、~/Desktop などに読み書きすると、権限エラーで失敗します。OSの更新で許可が外れることもあります

どこで動かすかの判断表

実行場所

向いている条件

止まる条件

手元のMac(launchd)

1日1回取れれば時刻は多少ずれてよい。Macを毎日開く

電源オフ・未ログイン・長期の外出。スリープ中は起きるまで遅れる

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

requests+BeautifulSoup だけで済む軽い取得。決まった時刻に動かしたい

事業者の実行時間・プロセス数の制限に触れたとき。ブラウザを入れられず Playwright が使えないことが多い。サーバー側の Python が古い場合(python3 -V で要確認)。制限の数値は各社の仕様ページで確認してください

VPS(systemd timer)

Playwright などヘッドレスブラウザが要る。時刻厳守。失敗を記録・通知したい

メモリ不足(ブラウザを閉じ忘れると増え続ける)、契約切れ、timer を enable し忘れたまま再起動したとき

目安は次のとおりです。

  • 「朝の時点の値が必要」「Macを持ち出す日がある」なら、手元から外へ移します
  • 取得が requests で完結するなら、共有サーバーの cron で足ります
  • JavaScriptで描画されるページを Playwright で取るなら VPS です。VPS では systemd timer の Persistent=true を使います。停止中に逃した回を、起動時に1回取り戻せます。cron には無い機能です。書き方は Playwright定期実行をsystemdで 2026年10月 で扱っています

毎日アクセスする前に確認する robots.txt・利用規約・アクセス間隔

定期実行は、一度設定すると人の目を離れて毎日相手のサーバーに届きます。だからこそ、登録前に次を確認します。

  • robots.txt:上のコードのように、実行のたびに確認します。禁止されたパスは取得しません
  • 利用規約:自動取得の禁止や、データの二次利用の条件を読みます。2026年は、AIによる無断収集への対策として規約を厳しくするサイトが増えています。Redditも、RSSを2026年11月13日に、公開データAPIを2027年3月までに終了すると発表しています。公式APIやフィードがあるならそれを優先し、提供が終わるならその取得はやめるのが筋です
  • アクセス間隔:1日1回・数ページで足りる設計にします。複数ページを回るなら、間に time.sleep() で数秒以上空けます。短時間の大量アクセスは、偽計業務妨害罪に問われうるとされています
  • ブロックされたら止まる:403やCAPTCHAが出たら、相手が拒否しているという意思表示です。IPの切り替えや検出回避で突破するのではなく、取得をやめるか、相手に問い合わせます
  • 個人情報:氏名や連絡先を含むページを集めると、個人情報保護法の対象になります。必要な項目だけを保存します

関連する選択肢

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

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

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

まとめ:launchctl bootstrap で登録し、止まる条件を知ったうえで置き場所を選ぶ

macOSで Python スクレイパーを毎日動かすなら、登録は launchctl bootstrap gui/$(id -u)、停止は bootout、有効・無効の記録は enable/disable で行います。レガシー扱いの load/unload は、読み込みと有効状態を混同させ、「再起動したら止まった・復活した」の原因になります。plist には絶対パスの venv の python と -u を書き、StandardOutPath でログを残し、launchctl print の last exit code で結果を確かめます。

launchd は、スリープ明けに逃した回を1回実行してくれます。ただし、電源オフと未ログインの間は動きません。時刻どおりに取りたい、Macを持ち出すという条件があるなら、外へ移します。軽い取得なら共有サーバーの cron、ブラウザが要るなら VPS の systemd timer が受け皿です。どこで動かす場合も、robots.txt・規約・間隔の確認を最初に組み込んでおきます。