Claude Media
Claude CodeでPlaywrightのダウンロードを自動化 — 請求書PDFの待ち方と保存名

Claude CodeでPlaywrightのダウンロードを自動化 — 請求書PDFの待ち方と保存名

expect_downloadでダウンロード完了を待ち、保存名を規則化して請求書PDFを集めるスクリプトを、認証情報をClaude Codeに渡さない権限設計と合わせて示します。

取引先の管理画面から毎月の請求書PDFを集める作業は、Playwrightのダウンロード処理に置き換えられます。要になるのは、クリックの直前に待機を始めるexpect_downloadと、サーバーが付けた名前をそのまま信じない保存名の規則です。

Claude Codeに書かせるときは、もう1つ決めることがあります。ログイン情報を読ませない設定と、スクリプトが書き込める場所の制限です。この記事ではPython版を例に、ダウンロードの待ち方、保存名の付け方、権限設計の順に組み立てます。

ダウンロードはクリックの前から待つ

Playwrightは、ページがダウンロードを発生させるたびにdownloadイベントを出します。ファイルは一時フォルダに落ち、イベントからDownloadオブジェクトを受け取れます。URL、ファイル名、中身のストリームはこのオブジェクトから取ります。

最も素直な書き方は、expect_downloadをコンテキストマネージャーとして使う形です。

with page.expect_download() as download_info:
    page.get_by_text("Download file").click()
 
download = download_info.value
download.save_as("/path/to/save/at/" + download.suggested_filename)

ポイントは順序です。待機を先に始めてから、ダウンロードを起こす操作をブロックの中に書きます。クリックしてから待ち始めると、イベントが先に出てしまう可能性があります。

expect_downloadは、操作を実行して新しいダウンロードを待つメソッドです。ページがダウンロード開始前に閉じられると、エラーを投げます。predicateを渡せば、受け取ったDownloadが条件を満たすまで待てます。請求書一覧に複数のリンクがあり、別のファイルも同時に落ちる画面では、この条件が選別に使えます。

同時に落ちるファイルを選り分ける

画面によっては、1回のクリックで請求書PDFと明細CSVのように複数のファイルが落ちます。predicateに条件を渡すと、条件が真になるダウンロードだけを受け取れます。

with page.expect_download(
    predicate=lambda d: d.suggested_filename.lower().endswith(".pdf")
) as info:
    row.get_by_role("link", name="一括取得").click()
 
pdf = info.value

条件に合わないダウンロードは待機の対象から外れるだけで、止める処理ではありません。拡張子で選り分けても、取り逃したファイルが必要ならイベントハンドラー方式との併用を検討します。

save_asは完了を待ってくれる

save_asは、ダウンロードを指定のパスへコピーします。ダウンロード中に呼んでも安全で、必要なら完了まで待ちます。つまり、保存の前に自前で完了待ちを書く必要はありません。

失敗の確認にはfailure()を使います。エラーがあれば文字列、なければNoneを返し、これも必要なら完了を待ちます。path()は成功したダウンロードのパスを返しますが、失敗や中断では例外になります。ファイル名はランダムなGUIDなので、人が読む名前にはsuggested_filenameを使います。

イベントハンドラー方式は業務スクリプトに向かない

何がダウンロードを起こすのか分からないときは、page.on("download", ...)でも受けられます。ただしPlaywrightの説明では、この方式は制御の流れを分岐させ、スクリプトを追いにくくします。ダウンロード中に本流が終わってしまう場面もありえます。

請求書の収集は、「この行のこのボタンを押すと、このファイルが落ちる」と対応が分かっている作業です。expect_downloadで1件ずつ待つ形が向いています。

保存名を規則化する

suggested_filenameは、サーバーが提案する名前です。取引先ごとにinvoice.pdfやdownload.pdfのように同じ名前が返ると、そのまま保存して上書きが起きます。そこで、保存名はスクリプト側の規則で組み立てます。

次の形にそろえると、月次の突合や再実行の確認が楽になります。

要素例役割
対象月例2026-09役割並べ替えと月次の取りまとめ
取引先の識別子例acme役割取引先ごとの絞り込み
請求番号例INV-0042役割突合のキー
拡張子例.pdf役割suggested_filenameから取得
import re
from pathlib import Path
 
OUT_DIR = Path("downloads/invoices")
 
def safe(text: str) -> str:
    return re.sub(r"[^A-Za-z0-9_-]", "_", text)
 
def save_invoice(download, month: str, vendor: str, number: str) -> Path:
    suffix = Path(download.suggested_filename).suffix or ".pdf"
    dest = OUT_DIR / f"{month}_{safe(vendor)}_{safe(number)}{suffix}"
    OUT_DIR.mkdir(parents=True, exist_ok=True)
    if dest.exists():
        return dest  # 取得済みの月はスキップして再実行を安全にする
    download.save_as(dest)
    return dest

サーバー由来の文字列をそのままパスに使わないのが、この関数の設計の核です。suggested_filenameに../のような区切りが含まれていても、保存名は月、取引先、請求番号と拡張子だけで決まります。画面から読み取った取引先名や請求番号も、safeで英数字と-_に落としてから使います。

存在確認を入れると、途中で止まった月次処理を再実行しても二重に保存しません。内容の同一性までは見ないため、同じ請求番号で再発行された請求書を取り直したいときは、削除してから再実行する運用になります。

一覧画面を回す全体像

行ごとにリンクを押す部分は、取引先の画面に合わせて変わります。骨格は次のとおりです。

from playwright.sync_api import sync_playwright
 
with sync_playwright() as p:
    browser = p.chromium.launch(downloads_path="tmp/pw-downloads")
    context = browser.new_context(storage_state="playwright/.auth/vendor.json")
    page = context.new_page()
    page.goto("https://billing.example.com/invoices")
 
    for row in page.locator("table.invoices tbody tr").all():
        number = row.locator("td.number").inner_text()
        month = row.locator("td.month").inner_text()
        with page.expect_download() as info:
            row.get_by_role("link", name="PDF").click()
        download = info.value
        if download.failure():
            print(f"失敗: {number}")
            continue
        save_invoice(download, month, "acme", number)
 
    context.close()
    browser.close()

セレクターとURLはダミーです。実際の画面に合わせた調整は、Claude Codeに実画面の構造を見せながら進めるのが早い部分です。見せる範囲は、後の「認証情報をClaude Codeに渡さない構成」で決めます。

ダウンロードの置き場所と寿命

ダウンロードされたファイルは、いったん一時フォルダに入ります。launch()のdownloads_pathで、その一時的な置き場所を指定できます。ただし、ブラウザコンテキストを閉じると、そのコンテキストで落としたファイルは削除されます。

この寿命が、先のsave_asを必須にしています。context.close()の後でパスを参照しても、ファイルは残っていません。業務スクリプトでは、閉じる前にすべてOUT_DIRへコピーしておく構成にします。

書き込み先を1か所に固めると、次の節の権限設計もしやすくなります。一時置き場と保存先を別のディレクトリにしておけば、「保存先だけを人が確認する」運用が成り立ちます。

認証情報をClaude Codeに渡さない構成

請求書の管理画面にはログインが要ります。この記事のスクリプトは、パスワードを直接扱わず、ログイン済みの状態ファイルを読む形にしています。状態ファイルの作り方と、読ませないための考え方はClaude CodeでPlaywrightのログイン状態を使い回すにまとめています。ここでは請求書の収集に必要な設定だけを置きます。

プロジェクトの.claude/settings.jsonに、状態ファイルと.envの読み取りを拒否するルールと、スクリプト実行の許可を書きます。

{
  "permissions": {
    "allow": [
      "Bash(python scripts/fetch_invoices.py *)"
    ],
    "deny": [
      "Read(./.env)",
      "Read(./playwright/.auth/**)"
    ]
  }
}

Readのdenyルールは、Claudeの組み込みのファイルツールを止めます。Read(./.env)やRead(./secrets/**)のようにパスを指定する形は、権限の説明に例として載っています。さらにReadのdenyは、同じパスへのEditとWriteも止め、新しいファイルの作成も含みます。

許可側のBashルールは、コマンド全体の文字列に対するパターンです。Bash(python scripts/fetch_invoices.py *)は、そのスクリプトを引数付きで実行する呼び出しだけを承認します。&&や;でつないだ別のコマンドは、それぞれ個別に判定されます。

権限ルールが止めるもの、止めないもの

ここで注意が要ります。ReadとEditのdenyルールは、組み込みのファイルツールと、catやsedのようにClaude Codeが認識するファイルコマンド、リダイレクトの対象を扱います。PythonやNodeのスクリプトが自分でファイルを開く動作は、対象外と説明されています。

つまり、Read(./playwright/.auth/**)を拒否しても、許可したfetch_invoices.py自体は状態ファイルを読めます。それが狙いなので問題ありません。逆に言えば、Editの許可範囲をdownloads/に絞っても、スクリプトの書き込み先は制限されません。スクリプトがdownloads/invoices以外へ書かないことは、コードの側で保証します。上のOUT_DIR固定とsafeによる名前の無害化が、その役目です。

OSレベルで、すべてのプロセスから特定のパスを遮断したい場合は、サンドボックスを有効にします。権限は「Claudeがどのツールで何をできるか」、サンドボックスは「実行されたコマンドが何に触れられるか」を受け持つ、別の層です。

ネットワークの出口も絞る

請求書の取得先は決まっています。Bashのdenyでcurlやwgetを止める方法もありますが、同じプログラムを別の形で呼ぶ場合は一致しません。確実に絞る必要があるなら、サンドボックスのネットワーク許可リストと組み合わせます。許可リストに請求書の取得先ドメインだけを置けば、スクリプトの通信先もそこに限られます。

つまずきやすい点

落とし穴

クリック後にexpect_downloadを書いて、待機が間に合わない

ダウンロードを起こす操作をwith page.expect_download()のブロックの外に書くと、イベントが待機の開始より先に出る場合があります。操作は必ずブロックの内側に置きます。

  • コンテキストを閉じた後にファイルがない: 一時フォルダのファイルは、コンテキストを閉じると削除されます。save_asは閉じる前に済ませます。
  • 画面が新しいタブでPDFを開くだけでダウンロードにならない: この場合はdownloadイベントが出ず、expect_downloadがタイムアウトする可能性があります。ボタンの挙動は、ブラウザで一度手動で確かめておきます。
  • 失敗が見えない: failure()がNoneでない行は、取得に失敗したものとして記録します。件数の突合は、保存した件数と画面の行数を比べるだけで足ります。

Claude Codeに書かせる指示の例

実画面を見ながら調整する段階では、指示に範囲を書き込むと安定します。

scripts/fetch_invoices.py を作って。
- expect_download のブロック内でリンクをクリックする
- 保存名は 対象月_取引先_請求番号.拡張子 に固定し、保存先は downloads/invoices のみ
- .env と playwright/.auth は読まない。ログインは storage_state のパスだけを使う
- 実行後、保存した件数と失敗した請求番号を表示する

最初は一覧の1行だけで実行させ、保存名と保存先が意図どおりか確かめてから全件に広げると、取り直しの手間を抑えられます。実行結果は、保存した件数と失敗の一覧を読ませます。取得した請求書の突合は別の工程で、入金データとの照合は入金消込をClaude Codeで自動化する手順から続けられます。Playwrightのスクリプトをhookから自動で走らせる設計は、PlaywrightのE2Eテストをhookで自動実行する記事と同じ考え方で組めます。

業務スクリプトとして運用する前に

取引先の規約が自動取得を禁じていないか、画面の操作を代行してよいかは、スクリプトの外の確認事項です。コードでは判断できません。管理画面にAPIやメール配信による請求書の受け取りがあるなら、そちらを先に検討する価値があります。

そのうえで画面操作が必要なら、次の3点を満たしておくと運用できます。

  • ダウンロードの待機はexpect_downloadで1件ずつ行う
  • 保存名と保存先はスクリプト側の規則で固定する
  • 認証情報はdenyルールで読ませず、状態ファイルのパスだけを渡す

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. playwright.dev/python/docs/downloads
  2. playwright.dev/python/docs/api/class-download
  3. code.claude.com/docs/en/permissions
この記事を共有:XはてブLinkedIn