この記事は、以下のような方を想定しています。

  • PythonのftplibでFTPアップロードを自動化したい方
  • Windowsのタスクスケジューラでバッチ処理を定期実行したい方
  • 「ローカルでは動くのにタスクスケジューラからだと動かない」現象に悩んだことがある方

前回の記事では、フロントエンド側のフィルタ設計とスマホ対応について紹介しました。 ここまでの工程で、データ取得・マージ・差分検出・画面表示までは一通り完成していたのですが、最後にもう一つ残っていた作業が 「ローカルPCで生成したファイルを、公開サーバーへどう届けるか」という部分でした。今回はそのFTPアップロード処理と、 それをタスクスケジューラから毎日自動実行する仕組みについてまとめます。

手元で完結させない:公開サーバーへの自動転送が必要だった理由

これまでの処理は、すべてローカルPC(自宅のWindows機)上で完結していました。データ取得・マージ・差分判定を行い、 最終的に表示用のindex.htmlと、各種CSV・JSONを含むdataフォルダをローカルに生成するところまでが main.pyの役割です。ところがこのファイル群は、レンタルサーバー上の公開ディレクトリに置かない限り、 誰の目にも触れません。当初は生成後に手動でFTPクライアントを開いてアップロードしていたのですが、これでは 「自動実行」の意味が半分なくなってしまいます。毎日決まった時刻にデータを更新し、かつ手を触れずに公開まで完了させるためには、 アップロード処理自体もPythonから自動で叩けるようにする必要がありました。

ftplibでindex.htmlとdataフォルダをアップロードする実装

標準ライブラリのftplibを使えば、追加のパッケージを入れずにFTP転送ができます。 今回作成したアップロード処理は、大きく分けて「index.html単体の転送」と「dataフォルダ配下を サブディレクトリ構造ごと再帰的に転送する処理」の2段構成にしています。

FTPアップロード処理の実装コード(接続情報はマスクして掲載)

実際のコードでは接続先・ユーザー名・パスワードを変数に直接書いていますが、この記事では公開しないよう ダミー値に置き換えています。後述する改善点でも触れますが、本番運用では環境変数などに分離するのが望ましい形です。

import ftplib
import os

def upload_to_ftp():
    FTP_HOST = os.environ.get("FTP_HOST")
    FTP_USER = os.environ.get("FTP_USER")
    FTP_PASS = os.environ.get("FTP_PASS")
    FTP_DEST_DIR = "/public_html/jpx-info.com/finance/urikin_mashitan/"

    with ftplib.FTP(FTP_HOST, FTP_USER, FTP_PASS) as ftp:
        ftp.cwd(FTP_DEST_DIR)

        # index.html のアップロード
        if os.path.exists("index.html"):
            with open("index.html", "rb") as f:
                ftp.storbinary("STOR index.html", f)

        # data フォルダ配下を再帰的にアップロード
        data_folder = "data"
        for root, dirs, files in os.walk(data_folder):
            rel_path = os.path.relpath(root, data_folder)
            ftp.cwd(FTP_DEST_DIR)
            try:
                ftp.mkd(data_folder)
            except Exception:
                pass
            ftp.cwd(data_folder)

            if rel_path != ".":
                for part in rel_path.replace("\\", "/").split("/"):
                    if part:
                        try:
                            ftp.mkd(part)
                        except Exception:
                            pass
                        ftp.cwd(part)

            for name in files:
                local_path = os.path.join(root, name)
                with open(local_path, "rb") as f:
                    ftp.storbinary(f"STOR {name}", f)

ポイントはos.walk()でローカルのdataフォルダをサブディレクトリまで含めて走査し、 同じ階層構造をリモート側にもftp.mkd()で作っていくところです。すでにディレクトリが存在する場合は mkd()がエラーを返しますが、これは想定済みの挙動なのでtry/exceptで握りつぶし、 既存ディレクトリへの移動だけを継続するようにしています。

ハマった点:cwdの基準位置を見失って迷子になる問題

最初に実装したときは、サブディレクトリへ移動したあとに次のループへ進む際、カレントディレクトリを 基準位置に戻す処理を入れていませんでした。その結果、1つ深い階層のサブフォルダに対してmkd()を実行した際、 想定していた場所ではなく、1つ前のループで移動した先のさらに下に新しいフォルダが作られてしまうという不具合が起きました。 FTPのcwdはローカルのカレントディレクトリと同じ感覚で「今いる場所」を保持し続けるため、 ループ内で移動した状態のままにしておくと、次のファイルの処理位置がずれていきます。

解決方法

  • サブディレクトリを走査するループの先頭で、必ずftp.cwd(FTP_DEST_DIR)を呼び基準位置に戻す
  • そこからdataフォルダへ移動し、必要なサブディレクトリへ1段ずつcwd()で進む
  • 「今どこにいるか」を毎回明示的にリセットすることで、深い階層が混在しても迷子にならない設計にする

ローカルのファイルパスを扱うときはos.path.join()などで絶対的な指定ができますが、FTPの cwdはサーバー側の「現在位置」という状態を持つため、ローカルのファイル操作とは異なる注意が必要だと 実感しました。

batファイルからPythonを2段階で呼び出す構成

データ生成処理(main.py)とFTPアップロード処理(scripts/ftp_upload.py)は、 役割をはっきり分けるために別ファイルにしています。これらをまとめて1回の実行で完結させるため、 以下のようなシンプルなbatファイルを用意しました。

@echo off
rem 1. プログラムのあるフォルダへ移動(これが重要)
cd /d "E:\temp\stocksoftware\urikin_mashitan"

rem 2. データ生成処理を実行
python main.py

rem 3. FTPアップロード処理を実行
python ./scripts/ftp_upload.py

rem 実行後にウィンドウを閉じたくない場合は以下を有効に(テスト用)
rem pause

データ生成とアップロードを1つのbatファイルにまとめておくことで、タスクスケジューラ側では 「このbatを1日1回実行する」というシンプルな登録だけで済むようにしています。仮にどちらかの処理だけを 手動で再実行したい場合も、同じbatの中身をコメントアウトすれば対応できる手軽さも気に入っています。

タスクスケジューラ登録でハマった「カレントディレクトリ」問題

batファイルが完成したら、Windowsのタスクスケジューラに登録して毎日自動実行されるようにします。 操作タブで「プログラムの開始」を選び、対象のbatファイルのフルパスを指定するだけなのですが、 最初に登録した際はmain.pyの中で相対パスでCSVファイルを読み書きしている部分がすべて 「ファイルが見つからない」というエラーで落ちてしまいました。

原因は、タスクスケジューラがプログラムを起動する際のカレントディレクトリが、batファイルの場所とは 無関係にC:\Windows\System32になっていたことでした。手動でbatファイルをダブルクリックして実行する場合は 「そのファイルが置かれているフォルダ」が自動的にカレントディレクトリになるため問題が起きませんが、 タスクスケジューラ経由ではこの前提が成立しません。

解決方法

batファイルの先頭にcd /d "E:\temp\stocksoftware\urikin_mashitan"を1行追加し、 明示的にカレントディレクトリを移動させることで解決しました。cdだけではドライブが異なる場合に 移動できないため、ドライブ変更も同時に行える/dオプションを付けるのがポイントです。 タスクスケジューラの「開始(オプション)」欄にフォルダを指定する方法もありますが、 batファイル内に明示しておくほうが、バッチファイル単体で実行しても確実に動作するため安心感があります。

登録時のタスクは、トリガーで毎日決まった時刻を指定し、操作で先述のbatファイルのフルパスを指定するだけの シンプルな構成です。PCがスリープ状態にならないよう設定タブの電源オプションも確認しておくと、確実に 毎日自動実行されるようになります。

今後の改善点:パスワードのハードコーディングをやめたい

動作確認を優先した結果、現状のスクリプトではFTPのホスト名・ユーザー名・パスワードを変数に 直接記述する形になっています。自分のPC上だけで動かすローカルスクリプトであり、リポジトリとして 公開する予定もないため当面の実害はないと考えていますが、設計としては褒められたものではありません。

今後はos.environ.get()で環境変数から読み込む形に変更するか、Gitの管理対象外にした 設定ファイル(.envなど)に分離する予定です。自動化スクリプトを書くときは、動かすことを 優先してまずはベタ書きしてしまうことが多いのですが、後から「これは直さないと」と気づける部分は 早めに棚上げにしておくのも一つの手だと感じています。

下図のようにPC起動している限りは、毎日自動で処理が動くように設定し、データ収集に要する時間はゼロになりました。

Windowsタスクスケジューラからのbat起動設定画面

FAQ

PythonのftplibでフォルダごとFTPアップロードするにはどうすればいいですか?

os.walk()でローカルのフォルダを再帰的に走査し、サブディレクトリごとにftp.mkd()で リモート側にディレクトリを作成してftp.cwd()で移動しながら、ファイルはftp.storbinary()で アップロードします。ループのたびに基準ディレクトリへcwdし直すのがポイントです。

Windowsタスクスケジューラからbatファイル経由でPythonを実行する際の注意点は?

batファイルの先頭でcd /dを使ってカレントディレクトリを明示的に移動しておくことが重要です。 タスクスケジューラはデフォルトのカレントディレクトリがC:\Windows\System32になっているため、 相対パスを使った処理がそのままでは動作しません。

FTPの接続情報をスクリプト内に直接書くのは問題ありますか?

動作確認のためにベタ書きすることはありますが、本番運用では環境変数や別ファイルの設定値として分離し、 スクリプト自体にはパスワードを直接残さない設計にするのが望ましいです。