Macでスクレイピングを毎日動かそうとして、launchdで登録したのに翌朝ファイルが増えていない。ターミナルから手で実行すると動くのに、launchdから起動すると~/Desktopに保存できない。こうした詰まり方をする人は多いです。launchctl bootstrapでの登録、StartCalendarIntervalがスリープ中にどう動くか、TCCの保護フォルダへの書き込みの3点を押さえれば、毎日止まらずに回ります。この記事では、今の書き方をplistと登録コマンドの実物つきで示します。

launchdで動かす前提のPythonスクレイピング本体(requests+BeautifulSoup+pandas)

実行環境

  • macOS(LaunchAgent=ログイン中のユーザー権限で動く常駐ジョブとして登録します)
  • Python 3.10以上を想定しています。手元のバージョンは python3 -V で確認してください
  • パッケージは requests・beautifulsoup4・pandas です。バージョンの固定は不要です。入っている版は pip show requests などで確認できます

Homebrewで入れたPythonに直接 pip install すると拒否されることがあります。その場合はプロジェクトごとにvenv(仮想環境)を作ります。経緯はexternally-managedはvenvで|26年10月にまとめました。launchdから呼ぶときも、venvの中のpythonを絶対パスで指定するのが一番確実です。

mkdir -p ~/scraper/logs ~/scraper/data
cd ~/scraper
python3 -m venv .venv
.venv/bin/pip install requests beautifulsoup4 pandas

スクリプト全体

公開ページから記事の見出しを集め、CSVに追記するだけの最小構成です。ただし、launchdで毎日動かすために必要な処理は全部入れてあります。

#!/usr/bin/env python3
"""公開ページの見出しを集めてCSVに追記する(launchdから1日1回)"""
import sys
import time
from datetime import date, datetime
from pathlib import Path
from urllib import robotparser
from urllib.parse import urljoin

import pandas as pd
import requests
from bs4 import BeautifulSoup

BASE = "https://example.com"
TARGETS = ["/news/", "/news/?page=2"]
UA = "daily-news-collector/1.0 (+https://example.com/contact)"

ROOT = Path.home() / "scraper"          # ~/Desktop・~/Documents は使わない
OUT_DIR = ROOT / "data"
LAST_RUN = ROOT / "last_run.txt"
MIN_INTERVAL = 2.0                      # リクエスト間隔(秒)


def done_today():
    return LAST_RUN.exists() and LAST_RUN.read_text().strip() == date.today().isoformat()


def load_robots(session):
    rp = robotparser.RobotFileParser()
    r = session.get(urljoin(BASE, "/robots.txt"), timeout=20)
    rp.parse(r.text.splitlines() if r.ok else [])
    return rp


def fetch(session, url, tries=4):
    err = None
    for i in range(tries):
        try:
            r = session.get(url, timeout=20)
        except (requests.ConnectionError, requests.Timeout) as e:
            err = e
        else:
            if r.status_code not in (429, 500, 502, 503, 504):
                r.raise_for_status()
                return r.text
            err = f"HTTP {r.status_code}"
        if i < tries - 1:
            wait = 5 * 2 ** i               # 5秒 → 10秒 → 20秒
            print(f"再試行まで{wait}秒: {err}", file=sys.stderr)
            time.sleep(wait)
    raise RuntimeError(f"取得できませんでした: {url} ({err})")


def parse(html, page_url):
    soup = BeautifulSoup(html, "html.parser")
    now = datetime.now().isoformat(timespec="seconds")
    return [
        {"fetched_at": now, "title": a.get_text(strip=True), "url": urljoin(page_url, a["href"])}
        for a in soup.select("article h2 a[href]")
    ]


def main():
    if done_today():
        print("本日分は取得済み")
        return 0

    session = requests.Session()
    session.headers["User-Agent"] = UA
    rp = load_robots(session)
    interval = max(MIN_INTERVAL, rp.crawl_delay(UA) or 0)

    rows = []
    for i, path in enumerate(TARGETS):
        url = urljoin(BASE, path)
        if not rp.can_fetch(UA, url):
            print(f"robots.txtで不許可のため飛ばす: {url}", file=sys.stderr)
            continue
        if i:
            time.sleep(interval)
        rows += parse(fetch(session, url), url)

    if not rows:
        print("1件も取れなかった(セレクタを確認)", file=sys.stderr)
        return 1

    OUT_DIR.mkdir(parents=True, exist_ok=True)
    out = OUT_DIR / "news.csv"
    pd.DataFrame(rows).to_csv(out, mode="a", header=not out.exists(),
                              index=False, encoding="utf-8-sig")
    LAST_RUN.write_text(date.today().isoformat())
    print(f"{len(rows)}件を追記: {out}")
    return 0


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

ブロックごとの説明

  • 定数部:保存先を Path.home() / "scraper" にしています。~/Desktop を避ける理由は後半のTCCの節で説明します。UA には連絡先URLを入れます。誰が何のために取得しているのかを、相手のサイトから分かるようにするためです
  • done_today():その日の分が済んでいれば何もせずに終わります。launchdに1日複数回の起動を登録しても、取得は1回で済みます(スリープ対策で使います)
  • load_robots():robots.txtはrequestsで取得し、標準ライブラリの RobotFileParser で解釈します。rp.read() は内部でurllibを使います。python.org版のPythonでは証明書の設定次第でSSLエラーになるため、取得だけrequestsに任せています
  • fetch():再試行するのは、接続エラー・タイムアウト・429/5xx(相手側が一時的に応答できない状態)のときだけです。404のように何度送っても結果が変わらないものは、その場で例外にします。待ち時間は5秒→10秒→20秒と倍々に伸ばします(指数バックオフ)。スリープ復帰直後はWi-Fiがまだつながっていないことがあるので、この再試行がlaunchd運用では特に効きます。429の扱いをもう少し詳しく書いたのがmethod_whitelist削除と429再試行|26年10月です
  • parse():article h2 a[href] は例です。div > div:nth-child(3) のように位置に頼るセレクタは、HTMLの小さな変更ですぐ壊れます。クラス名や属性など、意味のある目印を選んでください
  • main():1件も取れなかったときは終了コード1で終わります。launchdは終了コードを記録するので、セレクタが壊れたことに後から気づけます

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

  • robots.txtはサイト運営者の意思表示です。Disallow のURLは取りに行かず、Crawl-delay があれば上のコードのようにその秒数以上を空けます
  • アクセス間隔は最低でも1〜2秒空けます。状況によっては数秒以上にします。日本では、1秒に1回程度のアクセスで逮捕(のちに起訴猶予)された事例があります。短時間に大量のリクエストを送ると、業務妨害に問われる可能性もあります。並列化はしません
  • 利用規約も確認します。Amazon・楽天・Yahoo!ショッピングなど国内の主要ECサイトは、利用規約でスクレイピングを制限していることが多く、公式APIの利用を勧めています。APIで取れるものはAPIで取ってください
  • ログインが必要なページは対象にしません。ログイン制限を突破して取得すると、不正アクセス禁止法の問題になります
  • bot対策(CAPTCHAやIPブロック)が出たら、それは相手が「来ないでほしい」と示しているサインです。回避の手口を考えるのではなく、取得自体をやめるか、公式の提供手段を探します

launchctl load から bootstrap gui/$(id -u) へ:plistと登録コマンドの今の書き方

plist(~/Library/LaunchAgents/com.example.news-collector.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.news-collector</string>

  <key>ProgramArguments</key>
  <array>
    <string>/Users/username/scraper/.venv/bin/python</string>
    <string>/Users/username/scraper/collect.py</string>
  </array>

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

  <key>StartCalendarInterval</key>
  <array>
    <dict><key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer></dict>
    <dict><key>Hour</key><integer>13</integer><key>Minute</key><integer>0</integer></dict>
    <dict><key>Hour</key><integer>19</integer><key>Minute</key><integer>0</integer></dict>
  </array>

  <key>RunAtLoad</key>
  <true/>

  <key>EnvironmentVariables</key>
  <dict>
    <key>PATH</key><string>/usr/bin:/bin:/usr/sbin:/sbin</string>
    <key>PYTHONUNBUFFERED</key><string>1</string>
  </dict>

  <key>StandardOutPath</key>
  <string>/Users/username/scraper/logs/collect.log</string>
  <key>StandardErrorPath</key>
  <string>/Users/username/scraper/logs/collect.err</string>
</dict>
</plist>

plistで外せない点

  • パスはすべて絶対パスで書きます。plistの中では ~ も $HOME も展開されません。username は自分のユーザー名に置き換えます
  • pythonはvenvの中のものを直接指定します。source .venv/bin/activate はlaunchdからは使えません。venvのpythonを直接呼べば、activateしたのと同じパッケージが使えます
  • PATHは最小限になります。ターミナルで設定したPATHはlaunchdに引き継がれません。外部コマンドを呼ぶなら、EnvironmentVariables で明示するか、コード側で絶対パスにします
  • PYTHONUNBUFFERED を指定しておくと、print の出力がすぐログに書き出されます。途中で落ちたときに、どこまで進んだかが分かります
  • ログの出力先ディレクトリは先に作っておきます(冒頭の mkdir -p ~/scraper/logs)
  • StartCalendarInterval を配列にして、9時・13時・19時の3回起動するようにしています。取得はスクリプト側の done_today() で1日1回に絞ります。こうしている理由は次の節で説明します

登録コマンド:以前の書き方と今の書き方

やること

以前の書き方

今の書き方

登録

launchctl load ~/Library/LaunchAgents/…plist

launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/…plist

解除

launchctl unload …plist

launchctl bootout gui/$(id -u)/com.example.news-collector

状態確認

launchctl list | grep …

launchctl print gui/$(id -u)/com.example.news-collector

今すぐ実行

launchctl start …

launchctl kickstart -k gui/$(id -u)/com.example.news-collector

恒久的に止める

load -w / unload -w

launchctl disable gui/$(id -u)/com.example.news-collector

macOS 26 Tahoeで load の扱いが変わったという話を見かけます。ただ、Tahoeで具体的に何が変わったのかは、私が確認できた範囲では裏付けが取れませんでした。確かなのは、load・unload がかなり前から man launchctl で「LEGACY SUBCOMMANDS」(旧来のサブコマンド)に分類されていることです。新しく書くなら bootstrap/bootout を使うのが筋です。手元のmacOSでの扱いは man launchctl で確認してください。

gui/$(id -u) は「今ログインしている自分のGUIセッション」を指すドメイン名です。id -u は自分のユーザーID(数値)を返します。LaunchAgentはこのドメインに登録するので、ログインしていない間は動きません。

実際の登録手順

PLIST=~/Library/LaunchAgents/com.example.news-collector.plist
plutil -lint "$PLIST"                                   # 書式チェック。OKと出ればよい
launchctl bootout gui/$(id -u)/com.example.news-collector 2>/dev/null   # 登録済みなら外す
launchctl bootstrap gui/$(id -u) "$PLIST"
launchctl kickstart -k gui/$(id -u)/com.example.news-collector          # 1回試しに走らせる
launchctl print gui/$(id -u)/com.example.news-collector | grep -E "state|last exit code|runs"
tail ~/scraper/logs/collect.log ~/scraper/logs/collect.err
  • plistを編集したら、bootoutしてからbootstrapし直します。launchdは登録時にplistを読み込むだけで、ファイルを書き換えても登録内容は変わりません。登録済みの状態でもう一度bootstrapすると、Bootstrap failed: 5: Input/output error のように中身の分からないエラーになることが多いです。そのため、先にbootoutする手順を固定しています
  • launchctl print の last exit code が0なら成功です。スクリプトが1件も取れずに return 1 した日は1になります(表記はmacOSのバージョンで多少違います)
  • bootout はその場限りです。~/Library/LaunchAgents/ にplistが残っていると、次にログインしたときに読み直されて復活します。完全に止めたいときは、launchctl disable を実行するか、plistをこのフォルダの外へ移します。disableしたジョブは launchctl enable するまでbootstrapできないので、この点も覚えておいてください

StartCalendarIntervalのスリープ中の挙動と、~/DesktopのTCCでPermissionErrorになる仕様

スリープ中の回は「起床後に1回」にまとめられ、電源オフ中の回は実行されない

man launchd.plist によると、StartCalendarInterval はcronと違い、スリープ中に予定時刻を過ぎても捨てずに、次に起きたときに実行します。ただし、寝ている間に複数回の予定時刻を過ぎていたら、起床後の1回にまとめられます。そして電源が切れていた間の回は実行されません。

9:00の時点のMac

何が起きるか

起きている

9:00に実行される

スリープ中(フタを閉じていた)

次に起きたときに1回だけ実行される。起床直後はネットワークが未接続のことがある

電源オフ

実行されない。次の予定時刻(この記事の例では13:00)まで何も起きない

ログアウト中

LaunchAgentはそもそも動かない

このため、時刻を1つだけ登録すると「その時刻に電源が入っていなかった日」が丸ごと抜けます。上のplistで3回起動させ、さらに RunAtLoad(ログインして登録された時点で1回実行)を付けているのはこのためです。起動のきっかけは多めに用意し、1日1回への絞り込みはスクリプトの done_today() に任せます。役割をこう分けておけば、Macをいつ開いても、その日のうちに1回は取得できます。

決まった時刻に確実に起こしたい場合は、pmset の定期起床を併用する手もあります。

sudo pmset repeat wakeorpoweron MTWRFSU 08:55:00   # 毎日8:55に起床または起動
pmset -g sched                                      # 登録内容の確認
sudo pmset repeat cancel                            # 取り消し

ただし、フタを閉じたノートPCをバッテリーで動かしている場合など、機種や電源の状態によっては起床しないことがあります。私は「保険」程度に考えるのがよいと思います。

~/Desktopに保存するとlaunchdからだけPermissionErrorになる

macOSのTCC(Transparency, Consent, and Control:アプリごとにファイルへのアクセスを許可する仕組み)は、~/Desktop・~/Documents・~/Downloads などを保護しています。ターミナルから手で実行すると動くのは、ターミナルアプリ自体がアクセス許可を持っているからです。launchdから起動されたpythonはその許可を引き継がないので、同じコードでも保存の段階で次のように落ちます。

PermissionError: [Errno 1] Operation not permitted: '/Users/username/Desktop/news.csv'

対処は保存先を保護対象の外に移すことです。上のスクリプトが ~/scraper/data に書いているのはこのためです。~/Library/Application Support/ の下に専用フォルダを作るのでも構いません。

  • 以前の書き方:OUT = Path.home() / "Desktop" / "news.csv"(手元で試すと動くので、そのまま登録してしまいがち)
  • 今の書き方:OUT_DIR = Path.home() / "scraper" / "data"。デスクトップで見たいなら、Finderでエイリアスを置くだけにする

「フルディスクアクセス」をpythonに与えれば書き込めるようにはなります。しかしその場合、そのpythonで動くすべてのスクリプトが、保護フォルダ全体を読み書きできるようになります。また、OSのアップデートなどで許可が外れると、ある日突然また止まります。恒久的な対策にはならないので、私は勧めません。

毎日動かし続ける場所の選び方:手元のMac・共有サーバーのcron・VPSのsystemd timer

ここまでの設定で、Macが使える日には確実に動くようになります。それでも「毎日1回も欠かさない」ことが必要なら、実行場所そのものを見直す必要があります。それぞれどういう条件で止まるかで比べます。

実行場所

向いているもの

止まる条件

手元のMac(launchd)

個人用の集計、多少欠けても困らない取得

電源オフ・ログアウト中・スリープ中(起床後にまとめて1回)・OSアップデートの再起動待ち

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

requests+BeautifulSoupで済む軽い取得

実行時間やプロセス数の制限で強制終了される。プランによってはSelenium・Playwright用のブラウザを入れられない。cronはその時刻に動かなければ次の回まで待つ

VPS(systemd timer)

ブラウザ操作が要るもの、確実に毎日回したいもの

契約・支払いの停止、ディスクの空き不足、OSの更新後にタイマーが有効になっていない

判断の目安

  1. 1日くらい欠けても困らないなら、この記事のMac+launchdの構成で十分です
  2. requestsで取れて、毎日欠かせないなら、cronが使える共有レンタルサーバーに同じスクリプトを置くのが一番手軽です。動かす前に、そのプランでPythonのバージョンと pip install の可否を確認してください
  3. PlaywrightやSeleniumでブラウザを動かすなら、VPSが現実的です。systemd timerの Persistent=true を使うと、サーバーが止まっていた間の回を起動時に実行してくれます
# /etc/systemd/system/news-collector.timer(VPS側の例)
[Unit]
Description=daily news collector

[Timer]
OnCalendar=*-*-* 09:00:00
Persistent=true

[Install]
WantedBy=timers.target

対になる news-collector.service の ExecStart には、Macのplistと同じく、venvのpythonとスクリプトを絶対パスで書きます。タイマーを有効にしたら、systemctl list-timers で次回の実行時刻が出ているかを確認します。どの場所を選んでも、「終了コード0なのに成果物が増えていない」ことに気づける仕組みが要ります。この記事のスクリプトが、取得0件のときに1で終わるようにしているのはそのためです。

関連する選択肢

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

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

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

launchd+Pythonスクレイピングを止めないための確認項目

Macでスクレイピングを毎日動かすときは、登録を launchctl load ではなく launchctl bootstrap gui/$(id -u) で行います。plistを書き換えたらbootoutしてから登録し直し、恒久的に止めるときは disable を使うかplistを移します。plistの中は、venvのpythonもログの出力先も絶対パスで書きます。StartCalendarInterval は、スリープ中の回を起床後の1回にまとめ、電源オフ中の回は実行しません。起動の時刻を複数登録し、1日1回への絞り込みはスクリプト側で行います。保存先は ~/Desktop などTCCの保護フォルダを避けます。取得そのものは、robots.txt・利用規約・最低1〜2秒の間隔を守ります。一日も欠かせないなら、cronが使える共有サーバーやVPSへ移すことを検討してください。