GitHub Actions の on: schedule で定期スクレイピングを組んだのに、ある日から黙って動かなくなった。そんな経験はないでしょうか。リポジトリが60日間更新されないと schedule は自動で無効化されます。実行時刻も混雑で遅れ、回によっては実行されません。この記事では、requests・Playwright の収集スクリプトを schedule と workflow_dispatch で止まりにくく書く方法を示します。あわせて、どこから VPS の systemd timer へ移すべきかの判断基準も整理します。
GitHub Actions の schedule が「書いた時刻に動かない」2つの仕様
GitHub Actions は、サーバーを用意せずにPythonを定期実行できる手軽な場所です。ただし、cron(決まった時刻にコマンドを実行する仕組み)と同じ感覚で使うと、次の2つでつまずきます。
60日間更新が無いと schedule が自動で無効化される
- リポジトリに60日間更新が無いと、
on: scheduleのワークフローは自動で無効化されます。 - 無効化はエラーではありません。そのため失敗通知も来ず、Actions のタブに実行履歴が増えなくなるだけです。
- 対象となる条件(公開・非公開リポジトリの別など)は、GitHub の公式ドキュメント「Events that trigger workflows」の schedule の項で確認してください。
私が一番怖いと思うのはここです。スクリプト側でどれだけ例外処理を書いても、ワークフローそのものが起動しなければ何も記録されません。
実行時刻は遅れることがあり、混雑時は落ちることもある
- schedule は「その時刻ちょうどに起動する」約束ではありません。混雑すると開始が遅れ、実行されない回もあります。
- そのため、「直近1時間の分を取る」のように実行時刻を前提にした取得範囲は、取りこぼしを生みます。
- 多くの人が毎時0分を指定するので、その時刻は混みやすいと考えるのが自然です。私は
17のような半端な分を使っています。
この2つを踏まえた書き方の方針は、次の表のとおりです。
仕様 | 以前ありがちな書き方 | 今の書き方 |
|---|---|---|
実行が遅れる・落ちる | 実行時刻から逆算して「前回以降」を取る | 毎回一覧を取り、URLで重複を除いて蓄積する(何回落ちても次の回で追いつく) |
0件でも成功扱い | 取れなくても終了コード0で終わる | 0件なら終了コード1で落とし、Actions 上で「失敗」と表示させる |
60日で無効化 | 気づくまで放置 | 最終実行時刻をファイルに残し、データを使う側で鮮度を確認する |
手動で試せない | cron の時刻を一時的に書き換えて待つ |
|
requests+BeautifulSoup の収集スクリプトを「0件なら失敗」で書く
まず、Actions で動かす前提の収集スクリプトです。題材は、架空のサイト example.com のお知らせ一覧からタイトルとURLを集めて、CSVに蓄積する処理にします。
実行環境
- Python:手元で動作確認したバージョンに合わせます(
python3 -Vで確認)。Python 3.15 は2026年10月9日に正式リリースされたばかりです。依存ライブラリが対応版を出しているかを確かめてから上げるのが安全です。 - パッケージ:
pip install requests beautifulsoup4 pandas - バージョンは手元で動いた組み合わせを
pip freeze > requirements.txtで固定します。Actions の実行環境で勝手に新しい版が入ると、手元では動くのに Actions では落ちる、という切り分けにくい状態になるためです。固定する理由はbs4 text=非推奨→string=と版固定|26年10月でも詳しく書いています。
コード全体(scrape.py)
import argparse
import sys
import time
import urllib.robotparser
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urljoin
import pandas as pd
import requests
from bs4 import BeautifulSoup
BASE = "https://example.com"
OUT = Path("data/news.csv")
LAST_RUN = Path("data/last_run.txt")
UA = "news-collector/1.0 (+https://example.com/contact)"
INTERVAL = 3 # 1ページ取るごとに空ける秒数
def load_robots():
rp = urllib.robotparser.RobotFileParser(BASE + "/robots.txt")
rp.read()
return rp
def parse(html):
soup = BeautifulSoup(html, "html.parser")
rows = []
for a in soup.select("ul.news-list a[href]"):
rows.append({
"title": a.get_text(strip=True),
"url": urljoin(BASE, a["href"]),
})
return rows
def collect(pages):
rp = load_robots()
rows = []
with requests.Session() as s:
s.headers["User-Agent"] = UA
for n in range(1, pages + 1):
url = f"{BASE}/news/?page={n}"
if not rp.can_fetch(UA, url):
print(f"robots.txt で不許可: {url}", file=sys.stderr)
break
r = s.get(url, timeout=30)
r.raise_for_status()
found = parse(r.text)
if not found:
break
rows.extend(found)
time.sleep(INTERVAL)
return rows
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--pages", type=int, default=3)
args = ap.parse_args()
rows = collect(args.pages)
if not rows:
print("0件。ページ構造の変更かアクセス拒否の可能性", file=sys.stderr)
return 1
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
new = pd.DataFrame(rows).assign(fetched_at=now)
OUT.parent.mkdir(parents=True, exist_ok=True)
if OUT.exists():
old = pd.read_csv(OUT, encoding="utf-8")
before = len(old)
df = pd.concat([old, new]).drop_duplicates(subset="url", keep="first")
else:
before = 0
df = new
df.to_csv(OUT, index=False, encoding="utf-8")
LAST_RUN.write_text(now + "\n", encoding="utf-8")
print(f"取得 {len(rows)} 件 / 新規 {len(df) - before} 件")
return 0
if __name__ == "__main__":
sys.exit(main())
ブロックごとの説明
load_robots()とcan_fetch():取得先の robots.txt(クローラーにアクセスしてよい範囲を示すファイル)を読みます。不許可のURLには取りに行きません。parse():一覧のリンクからタイトルと絶対URLを抜き出します。urljoinを使うのは、/news/123のような相対パスを、重複判定に使える完全なURLにそろえるためです。collect():1ページずつ順番に取得し、毎回3秒空けます。並列では取りません。0件のページに来たら、それ以上は進みません。if not rows: return 1:ここが Actions 向けの肝です。サイト側の構造が変わったり、アクセスを拒否されて中身の無いページが返ったりすると、件数が0になります。それを「成功」で終わらせると、データが増えないまま毎日緑のチェックが付きます。終了コード1で落とせば、Actions 上で失敗として表示されます。drop_duplicates(subset="url", keep="first"):既存のCSVと今回の取得分を結合し、URLが同じ行は先に記録したほうを残します。何回か実行が落ちても、次に動いた回で一覧に残っている分を回収できます。実行時刻に依存しないので、schedule の遅延に強い書き方です。encoding="utf-8"を明示:Python 3.15 では PEP 686 により、指定しなくても UTF-8 が既定になりました。ただし、手元と Actions で Python のバージョンが違うこともあります。読み書きの両方で明示しておけば、どちらでも同じ結果になります。last_run.txt:成功した時刻だけを書きます。新規が0件の日でも更新されるので、後述の「止まっていないか」の確認に使います。
JavaScriptで描画されるページは Playwright に差し替える
最近はクライアントサイドレンダリング(ブラウザ上のJavaScriptで一覧を組み立てる作り)のサイトが増えています。この場合、requests で取ったHTMLには一覧が入っていません。そういうページは、collect() の取得部分だけを Playwright に差し替えます。
from playwright.sync_api import sync_playwright
def fetch_rendered(url):
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(user_agent=UA)
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
page.locator("ul.news-list a").first.wait_for(timeout=15_000)
html = page.content()
browser.close()
return html
gotoは DOM の構築が終わった時点で戻ります。そのあと、欲しい要素そのものが現れるのを待ちます。通信が静かになるのを待つ書き方は勧められなくなっています。経緯はPlaywright networkidle非推奨|26年10月にまとめました。- 15秒待っても要素が出なければ例外で落ちます。これも「0件を成功にしない」と同じ考え方です。
- 戻り値のHTMLは、そのまま上の
parse()に渡せます。
schedule と workflow_dispatch を併記したワークフローの書き方
次に、.github/workflows/collect.yml です。
name: collect
on:
schedule:
- cron: "17 0 * * *"
workflow_dispatch:
inputs:
pages:
description: "取得するページ数"
default: "3"
permissions:
contents: write
concurrency:
group: collect
cancel-in-progress: false
jobs:
collect:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: "3.13"
cache: pip
- run: pip install -r requirements.txt
# Playwright を使う場合だけ次の行を有効にする
# - run: python -m playwright install --with-deps chromium
- run: python scrape.py --pages "${{ inputs.pages || '3' }}"
- name: 結果をコミット
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add data/
git diff --cached --quiet || git commit -m "data: $(date -u +%F)"
git push
各ブロックが何をしているか
on::scheduleとworkflow_dispatchを並べて書きます。workflow_dispatchがあると、Actions の画面の「Run workflow」ボタンからいつでも手動実行できます。inputs.pagesでページ数を変えられるので、試すときは1を指定し、取得先へのアクセスを最小限に抑えます。schedule から起動したときはinputs.pagesが空になるため、|| '3'で既定値を入れています。- cron の時刻:GitHub Actions の schedule は UTC 基準として書くのが一般的です。
17 0 * * *なら日本時間の9時17分前後になります。ただ、タイムゾーンの扱いは仕様が変わりうるので、公式ドキュメントで確認してください。開始は遅れる前提で、その時刻ちょうどに動くことには依存させません。 permissions: contents: write:最後のステップでCSVをリポジトリにコミットするための権限です。付けないとgit pushが拒否されます。concurrency:遅れた schedule と手動実行が重なっても、同時に走るのは1本だけにします。2本が同時に同じCSVを書き換えて push すると、片方が競合で落ちるためです。cancel-in-progress: falseは「後から来た方は待つ」という意味です。timeout-minutes: 20:ブラウザが応答待ちで固まったときに、ジョブを打ち切ります。数日分のデータなら数分で終わるはずなので、それより十分長い値にしておきます。actions/checkoutとactions/setup-python:@の後ろのメジャー版は時期によって上がります。各アクションの GitHub リポジトリの Releases ページで最新を確認し、合わせて書き換えてください。python-versionには、手元でpython3 -Vを実行して確かめた版を書きます。- Playwright の行:Actions の実行環境には、ブラウザ本体も、ブラウザが依存するOSのライブラリも入っていません。
--with-depsで両方を入れます。requests だけで済むなら不要で、そのぶん実行時間が短くなります。 - 結果をコミット:
git diff --cached --quiet ||によって、差分があるときだけコミットします。last_run.txtは毎回変わるので、成功した日には必ず1コミットが積まれます。
60日無効化への向き合い方
- 上の構成では、収集が成功するたびにデータがコミットされます。動いている限り、リポジトリは自然に更新され続けます。
- ただし、無効化を避けるためだけに中身の無いコミットを自動で積む書き方はしません。逆に、収集が失敗し続けてコミットが止まれば、60日後に無効化されます。どちらにしても、止まったことに気づける仕組みのほうが要ります。
- 無効化されたら、Actions の画面か GitHub CLI の
gh workflow enable collect.ymlで再び有効にできます。
止まったことに気づく:データを使う側で鮮度を見る
ワークフローが起動しなければ、失敗も記録されません。そのため鮮度の確認は、Actions の外、つまり集めたCSVを読む側の処理に入れます。
from datetime import datetime, timedelta, timezone
from pathlib import Path
last = datetime.fromisoformat(Path("data/last_run.txt").read_text(encoding="utf-8").strip())
if datetime.now(timezone.utc) - last > timedelta(days=2):
print(f"収集が止まっている可能性: 最終成功 {last.isoformat()}")
毎日回す前提なら、2日以上空いた時点で、遅延ではなく停止を疑ってよい状態です。この警告を、普段見ている通知先に流します。
取得先サイトへの配慮:robots.txt・利用規約・3秒間隔
定期実行は、人が手で回すよりアクセスの総量が増えやすくなります。私は次の5点を最低限守っています。
- robots.txt を毎回読む:上のスクリプトは実行のたびに読み直します。サイト側が方針を変えたら、その日から従います。
- 利用規約を確認し、定期的に読み直す:規約は変わります。たとえば Meta は2026年10月30日付で商用規約を改定し、クローラーとAI学習に関する条項を追加すると報じられています。取得先の規約がいつ変わったかは、相手は知らせてくれません。
- 1ページずつ、間隔を空ける:並列で取らず、1ページごとに3秒以上空けます。相手の更新頻度より細かく回しても新しい情報は増えないので、1日1回で足りるなら1日1回にします。
- User-Agent で身元と連絡先を示す:ブラウザを名乗らず、何のためのアクセスかが分かる名前と連絡先URLを入れます。
- 拒否されたら止める:2026年はbot対策が強化され、空のHTMLやチャレンジページ(ボットかどうかを確かめる確認画面)が返る例が増えています。プロキシで接続元を替える、ブラウザらしく偽装するといった手で拒否をすり抜けるのは、相手の意思に反するので行いません。0件で落とす作りにしておけば、拒否されたときにすぐ気づけます。そのうえで、公式APIやデータ提供の有無を確認するか、運営者に問い合わせます。
なお、GitHub Actions は多くの利用者が共有するクラウドの実行環境です。手元のPCからは取れるのに Actions からだけ拒否される、ということも起こりえます。これは仕様上避けられないので、次の節の「移す判断」に入れています。
毎日動かし続けるには:Actions に残すか VPS の systemd timer へ移すか
実行場所ごとの「止まる条件」
実行場所 | 向く処理 | 止まる条件 |
|---|---|---|
手元のPC(cron・launchd) | 試作、1日1回で取り逃しても困らない収集 | スリープ・電源オフ・ログアウト。macOS の launchd は、スリープ中の時刻に実行されない |
共有レンタルサーバーの cron | requests+BeautifulSoup の軽い収集 | 実行時間やプロセス数の制限で強制終了される。ブラウザの依存ライブラリを入れる権限が無いことが多く、Playwright は動かしにくい |
GitHub Actions の schedule | 数分で終わる1日数回の収集。結果をリポジトリに残したいもの | 60日間の無更新で無効化。混雑で遅延・スキップ。取得先がクラウドの実行環境からのアクセスを拒否する。非公開リポジトリは実行時間に無料枠があり、上限と料金は公式の料金ページで確認 |
VPS の systemd timer | 時刻が重要な収集、長時間の処理、Playwright を毎日回すもの | 契約切れ・障害・自分の運用ミス(ディスク満杯、timer の無効化)。 |
VPS へ移すべきタイミング
- 実行時刻にずれが許されない:開店前に価格を集める、締め切り前に在庫を確認する、など。
- 1回の処理が長い、または Playwright を毎日回す:毎回ブラウザを入れ直す時間が無駄になります。VPS なら一度入れれば済みます。
- Actions からのアクセスだけ拒否される:VPS もデータセンターの回線なので、移しても解決するとは限りません。ただ、接続元のIPアドレスが固定になるので、運営者に事情を説明して許可を求める道が開けます。
- 取得結果に、リポジトリへ置けない情報が含まれる:社内向けの集計などです。
同じスクリプトを systemd timer で動かす
scrape.py はそのまま使えます。VPS 上の /home/username/collector/ に置き、仮想環境(python3 -m venv .venv で作ったプロジェクト専用のPython環境)に requirements.txt を入れておきます。
# /home/username/.config/systemd/user/collect.service
[Unit]
Description=news collector
[Service]
Type=oneshot
WorkingDirectory=/home/username/collector
ExecStart=/home/username/collector/.venv/bin/python scrape.py --pages 3
# /home/username/.config/systemd/user/collect.timer
[Unit]
Description=run news collector daily
[Timer]
OnCalendar=*-*-* 09:17:00
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
ExecStartは仮想環境の python を絶対パスで指定します。定期実行では PATH(コマンドを探す場所の一覧)が対話シェルと違います。pythonとだけ書くと、別のPythonが起動してライブラリが見つからない、というのが定番のつまずきです。WorkingDirectory:scrape.py はdata/news.csvを相対パスで書くので、作業ディレクトリを固定します。OnCalendar:サーバーのローカル時刻で解釈されます。タイムゾーンはtimedatectlで確認します。Persistent=true:再起動などで逃した回を、起動後に1回実行します。RandomizedDelaySec=300:開始を最大5分ばらします。毎日まったく同じ秒にアクセスすることを避けるためです。
有効化と確認は次のとおりです。
systemctl --user daemon-reload
systemctl --user enable --now collect.timer
systemctl --user list-timers # 次回の実行予定が出ていれば登録済み
systemctl --user start collect.service # 今すぐ1回試す(workflow_dispatch の代わり)
journalctl --user -u collect.service # 標準出力・標準エラーの記録
sudo loginctl enable-linger username # ログアウト後も user timer を動かす
最後の enable-linger を忘れると、SSH を切った時点で timer が止まります。scrape.py は0件のとき終了コード1を返すので、systemctl --user status collect.service にも失敗として残ります。Actions で使った「0件を失敗にする」作りは、移した先でもそのまま役に立ちます。
関連する選択肢
毎日決まった時刻にスクリプトを動かすだけなら、共有のレンタルサーバーでも足ります。cronが使えるプランを選べば、自分でOSを管理する必要はありません。
※ 広告を含みます(A8.net)。リンク経由でお申し込みがあった場合、手数料を受け取ることがあります。
まとめ:GitHub Actions の定期スクレイピングは「止まる前提」で書く
GitHub Actions の on: schedule は手軽ですが、2つの性質があります。リポジトリが60日間更新されないと自動で無効化されること、そして実行時刻が混雑で遅れたり実行されなかったりすることです。そのため、収集スクリプトは実行時刻に頼らず、毎回一覧を取ってURLで重複を除きます。0件なら終了コード1で落とし、成功時刻を last_run.txt に残してデータを使う側で鮮度を見ます。ワークフローには workflow_dispatch を併記し、concurrency と timeout-minutes で重複実行と固まりを防ぎます。時刻が重要な処理、Playwright を毎日回す処理、Actions からだけ拒否される場合は、VPS の systemd timer へ移します。その際は仮想環境の python を絶対パスで指定し、Persistent=true と enable-linger を付けます。どこで動かしても、robots.txt と利用規約を確認し、1ページずつ間隔を空けることが前提です。